diff --git a/Makefile.emscripten b/Makefile.emscripten
index 3a52414..31960f1 100644
--- a/Makefile.emscripten
+++ b/Makefile.emscripten
@@ -13,7 +13,7 @@ LFLAGS := $(EMFLAGS)
LFLAGS += -s EXPORTED_FUNCTIONS="['_main']"
LFLAGS += --preload-file gamedata@/
-xsystem35.html: xsystem35.bc emterpretify_whitelist src/xsystem35
+docs/xsystem35.js: xsystem35.bc emterpretify_whitelist src/xsystem35
$(CC) $< $(LFLAGS) $(shell grep '^ *-s' emterpretify_whitelist) -o $@
xsystem35.bc:
diff --git a/docs/xsystem35.html b/docs/xsystem35.html
new file mode 100644
index 0000000..7105a84
--- /dev/null
+++ b/docs/xsystem35.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ xsystem35 for web
+
+
+
+
+
+
+
+
+
+ -
KICHIKUOU.img
+ -
KICHIKUOU.cue
+
+
+
+
+
+
+
+
+ Downloading...
+
+
+
+
+
+
+
+ Resize canvas
+ Lock/hide mouse pointer
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ts-worker/cdimage.ts b/ts-worker/cdimage.ts
new file mode 100644
index 0000000..8a37d0c
--- /dev/null
+++ b/ts-worker/cdimage.ts
@@ -0,0 +1,280 @@
+class ISO9660FileSystem {
+ private pvd: PVD;
+
+ constructor(private sectorReader: CDImageReader) {
+ this.pvd = new PVD(sectorReader.readSector(0x10));
+ if (this.pvd.type != 1)
+ throw('PVD not found');
+ }
+
+ volumeLabel(): string {
+ return this.pvd.volumeLabel();
+ }
+
+ rootDir(): DirEnt {
+ return this.pvd.rootDirEnt();
+ }
+
+ getDirEnt(name:string, parent:DirEnt): DirEnt {
+ name = name.toLowerCase();
+ for (var e of this.readDir(parent)) {
+ if (e.name.toLowerCase() == name)
+ return e;
+ }
+ return null;
+ }
+
+ readDir(dirent:DirEnt): DirEnt[] {
+ var sector = dirent.sector;
+ var position = 0;
+ var length = dirent.size;
+ var entries:DirEnt[] = [];
+ while (position < length) {
+ if (position == 0)
+ var buf = this.sectorReader.readSector(sector);
+ var child = new DirEnt(buf, position);
+ if (child.length == 0) {
+ // Padded end of sector
+ position = 2048;
+ } else {
+ entries.push(child);
+ position += child.length;
+ }
+ if (position > 2048)
+ throw('dirent across sector boundary');
+ if (position == 2048) {
+ sector++;
+ position = 0;
+ length -= 2048;
+ }
+ }
+ return entries;
+ }
+
+ readFile(dirent:DirEnt, callback: (data:Uint8Array[]) => void) {
+ this.sectorReader.readSequentialSectors(dirent.sector, dirent.size, callback);
+ }
+}
+
+class PVD {
+ private view:DataView;
+ constructor(private buf:ArrayBuffer) {
+ this.view = new DataView(buf);
+ }
+ get type(): number {
+ return this.view.getUint8(0);
+ }
+ volumeLabel(): string {
+ var decoder = new TextDecoder('shift_jis');
+ return decoder.decode(new DataView(this.buf, 40, 32)).trim();
+ }
+ rootDirEnt(): DirEnt {
+ return new DirEnt(this.buf, 156);
+ }
+}
+
+class DirEnt {
+ private view:DataView;
+ constructor(private buf:ArrayBuffer, private offset:number) {
+ this.view = new DataView(buf, offset);
+ }
+ get length(): number {
+ return this.view.getUint8(0);
+ }
+ get sector(): number {
+ return this.view.getUint32(2, true);
+ }
+ get size(): number {
+ return this.view.getUint32(10, true);
+ }
+ get isDirectory(): boolean {
+ return (this.view.getUint8(25) & 2) != 0;
+ }
+ get name(): string {
+ var len = this.view.getUint8(32);
+ var decoder = new TextDecoder('shift_jis');
+ return decoder.decode(new DataView(this.buf, this.offset+33, len)).split(';')[0];
+ }
+}
+
+interface CDImageReader {
+ readSector(sector:number): ArrayBuffer;
+ readSequentialSectors(startSector: number, length: number, callback:(data:Uint8Array[])=>void): void;
+ maxTrack(): number;
+ extractTrack(track:number): Blob;
+}
+
+class ImageReaderBase {
+ constructor(public image:File) {}
+
+ readSequential(startOffset:number,
+ bytesToRead:number,
+ blockSize:number,
+ sectorSize:number,
+ sectorOffset:number,
+ callback:(data:Uint8Array[])=>void) {
+ var sectors = Math.ceil(bytesToRead / sectorSize);
+ var chunk = 256;
+ var reader = new FileReaderSync();
+ while (sectors > 0) {
+ var n = Math.min(chunk, sectors);
+ var blob = this.image.slice(startOffset, startOffset + n * blockSize);
+ var buf = reader.readAsArrayBuffer(blob);
+ var bufs:Uint8Array[] = [];
+ for (var i = 0; i < n; i++) {
+ bufs.push(new Uint8Array(buf, i * blockSize + sectorOffset, Math.min(bytesToRead, sectorSize)));
+ bytesToRead -= sectorSize;
+ }
+ callback(bufs);
+ sectors -= n;
+ startOffset += n * blockSize;
+ }
+ }
+}
+
+class ImgCueReader extends ImageReaderBase implements CDImageReader {
+ private tracks:{ type:string; index:string[]; }[];
+
+ constructor(img:File, cue:File) {
+ super(img);
+ this.parseCue(cue);
+ }
+
+ readSector(sector:number): ArrayBuffer {
+ var start = sector * 2352 + 16;
+ var end = start + 2048;
+ return new FileReaderSync().readAsArrayBuffer(this.image.slice(start, end));
+ }
+
+ readSequentialSectors(startSector: number, length: number, callback:(data:Uint8Array[])=>void) {
+ this.readSequential(startSector * 2352, length, 2352, 2048, 16, callback);
+ }
+
+ private parseCue(cueFile:File) {
+ var lines = new FileReaderSync().readAsText(cueFile).split('\n');
+ this.tracks = [];
+ var currentTrack:number = null;
+ for (var line of lines) {
+ var fields = line.trim().split(/\s+/);
+ switch (fields[0]) {
+ case 'TRACK':
+ currentTrack = Number(fields[1]);
+ this.tracks[currentTrack] = {type:fields[2], index:[]};
+ break;
+ case 'INDEX':
+ if (currentTrack)
+ this.tracks[currentTrack].index[Number(fields[1])] = fields[2];
+ break;
+ }
+ }
+ }
+
+ maxTrack():number {
+ return this.tracks.length - 1;
+ }
+
+ extractTrack(track:number): Blob {
+ if (!this.tracks[track] || this.tracks[track].type != 'AUDIO')
+ return;
+
+ var start = this.indexToSector(this.tracks[track].index[1]) * 2352;
+ var end:number;
+ if (this.tracks[track+1]) {
+ var index = this.tracks[track+1].index[0] || this.tracks[track+1].index[1];
+ end = this.indexToSector(index) * 2352;
+ } else {
+ end = this.image.size;
+ }
+ var size = end - start;
+ return new Blob([createWaveHeader(size), this.image.slice(start, start + size)], {type : 'audio/wav'});
+ }
+
+ private indexToSector(index:string):number {
+ var msf = index.split(':').map(Number);
+ return msf[0]*60*75 + msf[1]*75 + msf[2];
+ }
+}
+
+enum MdsTrackMode { Audio = 0xa9, Mode1 = 0xaa };
+
+class MdfMdsReader extends ImageReaderBase implements CDImageReader {
+ private tracks:{ mode:number; sectorSize:number; offset:number; sectors:number; }[];
+
+ constructor(mdf:File, mds:File) {
+ super(mdf);
+ this.parseMds(mds);
+ }
+
+ private parseMds(mdsFile:File) {
+ var buf = new FileReaderSync().readAsArrayBuffer(mdsFile);
+
+ var signature = new TextDecoder().decode(new DataView(buf, 0, 16));
+ if (signature != 'MEDIA DESCRIPTOR')
+ throw mdsFile.name + ': not a mds file';
+
+ var header = new DataView(buf, 0, 0x70);
+ var entries = header.getUint8(0x62);
+
+ this.tracks = [];
+ for (var i = 0; i < entries; i++) {
+ var trackData = new DataView(buf, 0x70 + i * 0x50, 0x50);
+ var extraData = new DataView(buf, 0x70 + entries * 0x50 + i * 8, 8);
+ var mode = trackData.getUint8(0x00);
+ var track = trackData.getUint8(0x04);
+ var sectorSize = trackData.getUint16(0x10, true);
+ var offset = trackData.getUint32(0x28, true); // >4GB offset is not supported.
+ var sectors = extraData.getUint32(0x4, true);
+ if (track < 100)
+ this.tracks[track] = {mode:mode, sectorSize:sectorSize, offset:offset, sectors:sectors};
+ }
+ if (this.tracks[1].mode != MdsTrackMode.Mode1)
+ throw 'track 1 is not mode1';
+ }
+
+ readSector(sector:number): ArrayBuffer {
+ var start = sector * this.tracks[1].sectorSize + 16;
+ var end = start + 2048;
+ return new FileReaderSync().readAsArrayBuffer(this.image.slice(start, end));
+ }
+
+ readSequentialSectors(startSector: number, length: number, callback:(data:Uint8Array[])=>void) {
+ var track = this.tracks[1];
+ this.readSequential(track.offset + startSector * track.sectorSize, length, track.sectorSize, 2048, 16, callback);
+ }
+
+ maxTrack():number {
+ return this.tracks.length - 1;
+ }
+
+ extractTrack(track:number): Blob {
+ if (!this.tracks[track] || this.tracks[track].mode != MdsTrackMode.Audio)
+ return;
+
+ var size = this.tracks[track].sectors * 2352;
+ var chunks: any[] = [createWaveHeader(size)];
+
+ this.readSequential(this.tracks[track].offset, size, this.tracks[track].sectorSize, 2352, 0,
+ buf => chunks.push(buf));
+
+ return new Blob(chunks);
+ }
+}
+
+function createWaveHeader(size:number):ArrayBuffer {
+ var buf = new ArrayBuffer(44);
+ var view = new DataView(buf);
+ view.setUint32(0, 0x52494646, false); // 'RIFF'
+ view.setUint32(4, size + 36, true); // filesize - 8
+ view.setUint32(8, 0x57415645, false); // 'WAVE'
+ view.setUint32(12, 0x666D7420, false); // 'fmt '
+ view.setUint32(16, 16, true); // size of fmt chunk
+ view.setUint16(20, 1, true); // PCM format
+ view.setUint16(22, 2, true); // stereo
+ view.setUint32(24, 44100, true); // sampling rate
+ view.setUint32(28, 176400, true); // bytes/sec
+ view.setUint16(32, 4, true); // block size
+ view.setUint16(34, 16, true); // bit/sample
+ view.setUint32(36, 0x64617461, false); // 'data'
+ view.setUint32(40, size, true); // data size
+ return buf;
+}
diff --git a/ts-worker/imagereader-worker.ts b/ts-worker/imagereader-worker.ts
new file mode 100644
index 0000000..8a61594
--- /dev/null
+++ b/ts-worker/imagereader-worker.ts
@@ -0,0 +1,100 @@
+importScripts('cdimage.js');
+
+declare function postMessage(data: any, transfers:any): void;
+
+class Installer {
+ private imgFile:File;
+ private cueFile:File;
+ private imageReader:CDImageReader;
+
+ constructor() {
+ addEventListener('message', this.onMessage.bind(this));
+ }
+
+ private onMessage(evt: MessageEvent) {
+ switch (evt.data.command) {
+ case 'setFile':
+ this.setFile(evt.data.file);
+ var imgName = this.imgFile && this.imgFile.name;
+ var cueName = this.cueFile && this.cueFile.name;
+ postMessage({command:'readyState', img:imgName, cue:cueName});
+ break;
+ case 'extractFiles':
+ if (this.ready())
+ this.extractFiles();
+ break;
+ case 'getTrack':
+ if (this.ready())
+ this.getTrack(evt.data.track);
+ break;
+ }
+ }
+
+ private setFile(file:File) {
+ var name = file.name.toLowerCase();
+ if (name.endsWith('.img') || name.endsWith('.mdf'))
+ this.imgFile = file;
+ else if (name.endsWith('.cue') || name.endsWith('.mds'))
+ this.cueFile = file;
+ if (this.imgFile && this.cueFile) {
+ if (this.cueFile.name.endsWith('.cue'))
+ this.imageReader = new ImgCueReader(this.imgFile, this.cueFile);
+ else
+ this.imageReader = new MdfMdsReader(this.imgFile, this.cueFile);
+ }
+ }
+
+ private ready(): boolean {
+ return !!this.imageReader;
+ }
+
+ private extractFiles() {
+ var isofs = new ISO9660FileSystem(this.imageReader);
+ //this.walk(isofs, isofs.rootDir(), '/');
+ var gamedata = isofs.getDirEnt('gamedata', isofs.rootDir()) ||
+ isofs.getDirEnt('mugen', isofs.rootDir());
+ if (!gamedata) {
+ postMessage({command:'error', message:'インストールできません。GAMEDATAフォルダが見つかりません。'});
+ return;
+ }
+ var files: any = {};
+ var transfers = [];
+ for (var e of isofs.readDir(gamedata)) {
+ if (e.name.toLowerCase().endsWith('.dat')) {
+ var buffer = new ArrayBuffer(e.size);
+ var uint8 = new Uint8Array(buffer);
+ var ptr = 0;
+ isofs.readFile(e, (bufs) => {
+ for (var buf of bufs) {
+ uint8.set(buf, ptr);
+ ptr += buf.byteLength;
+ }
+ });
+ if (ptr != e.size)
+ throw('expected ' + e.size + ' bytes, but read ' + ptr + 'bytes');
+ files[e.name] = buffer;
+ transfers.push(buffer);
+ }
+ }
+ postMessage({command: 'extractFiles',
+ volumeLabel: isofs.volumeLabel(),
+ files: files}, transfers);
+ }
+
+ private walk(isofs:ISO9660FileSystem, dir:DirEnt, dirname:string) {
+ for (var e of isofs.readDir(dir)) {
+ if (e.name != '\0' && e.name != '\x01') {
+ console.log(dirname + e.name);
+ if (e.isDirectory)
+ this.walk(isofs, e, dirname + e.name + '/');
+ }
+ }
+ }
+
+ private getTrack(track:number) {
+ var blob = this.imageReader.extractTrack(track);
+ postMessage({command:'complete', track:track, wav:blob});
+ }
+}
+
+var installer = new Installer();
diff --git a/ts-worker/package.json b/ts-worker/package.json
new file mode 100644
index 0000000..e742226
--- /dev/null
+++ b/ts-worker/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "xsystem35-shell-worker",
+ "version": "1.0.0",
+ "description": "",
+ "dependencies": {
+ "@types/text-encoding": "0.0.30"
+ }
+}
diff --git a/ts-worker/tsconfig.json b/ts-worker/tsconfig.json
new file mode 100644
index 0000000..57180ae
--- /dev/null
+++ b/ts-worker/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es6",
+ "outDir": "../docs",
+ "lib": ["es6", "webworker"],
+ "noImplicitAny": true,
+ "sourceMap": false
+ }
+}
\ No newline at end of file
diff --git a/ts/package.json b/ts/package.json
new file mode 100644
index 0000000..81dcf6d
--- /dev/null
+++ b/ts/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "xsystem35-shell",
+ "version": "1.0.0",
+ "description": "",
+ "dependencies": {
+ "@types/emscripten": "0.0.29"
+ }
+}
diff --git a/ts/shell.ts b/ts/shell.ts
new file mode 100644
index 0000000..84c793d
--- /dev/null
+++ b/ts/shell.ts
@@ -0,0 +1,199 @@
+var $: (selector:string)=>HTMLElement = document.querySelector.bind(document);
+
+declare namespace Module {
+ var noInitialRun: boolean;
+
+ // Undocumented methods / attributes
+ var canvas: HTMLElement;
+ function setStatus(status: string): void;
+ function monitorRunDependencies(left: number): void;
+ function callMain(): void;
+
+ var cdPlayer: CDPlayer;
+};
+
+declare namespace FS {
+ function writeFile(path: string, data: ArrayBufferView | string, opts?: {encoding?: string; flags?: string}): void;
+};
+
+class Sys3Shell {
+ private imageLoader: ImageLoader;
+ status: HTMLElement = document.getElementById('status');
+ progress: HTMLProgressElement = document.getElementById('progress');
+
+ constructor() {
+ this.imageLoader = new ImageLoader(this);
+ this.setStatus('Downloading...');
+ window.onerror = () => {
+ this.setStatus('Exception thrown, see JavaScript console');
+ this.setStatus = (text: string) => {
+ if (text) Module.printErr('[post-exception status] ' + text);
+ };
+ };
+
+ // Initialize the Module object
+ Module.print = Module.printErr = console.log.bind(console);
+ Module.canvas = document.getElementById('canvas');
+ Module.noInitialRun = true;
+ Module.setStatus = this.setStatus.bind(this);
+ Module.monitorRunDependencies = this.monitorRunDependencies.bind(this);
+
+ Module.cdPlayer = new CDPlayer(this.imageLoader);
+ }
+
+ setStatus(text: string) {
+ var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
+ var now = Date.now();
+ if (m && now - Date.now() < 30) return; // if this is a progress update, skip it if too soon
+ if (m) {
+ text = m[1];
+ this.progress.value = parseInt(m[2])*100;
+ this.progress.max = parseInt(m[4])*100;
+ this.progress.hidden = false;
+ } else {
+ this.progress.value = null;
+ this.progress.max = null;
+ this.progress.hidden = true;
+ }
+ this.status.innerHTML = text;
+ }
+
+ totalDependencies: number = 0;
+ monitorRunDependencies(left: number) {
+ this.totalDependencies = Math.max(this.totalDependencies, left);
+ this.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
+ }
+}
+
+class CDPlayer {
+ audio = $('audio');
+ private blobs: Blob[];
+
+ constructor(private imageLoader:ImageLoader) {
+ this.blobs = [];
+ }
+
+ play(track:number, loop:number) {
+ if (this.blobs[track]) {
+ this.startPlayback(this.blobs[track]);
+ return;
+ }
+ this.imageLoader.getCDDA(track, (blob) => {
+ this.blobs[track] = blob;
+ this.startPlayback(blob);
+ });
+ }
+
+ stop() {
+ this.audio.pause();
+ }
+
+ private startPlayback(blob:Blob) {
+ this.audio.setAttribute('src', URL.createObjectURL(blob));
+ this.audio.load();
+ this.audio.play();
+ }
+}
+
+class ImageLoader {
+ private worker: Worker;
+ private cddaCallback: (wab:Blob)=>void;
+
+ constructor(private shell:Sys3Shell) {
+ this.initWorker();
+ $('#fileselect').addEventListener('change', this.handleFileSelect.bind(this), false);
+ document.body.ondragover = this.handleDragOver.bind(this);
+ document.body.ondrop = this.handleDrop.bind(this);
+ }
+
+ getCDDA(track:number, callback:(wav:Blob)=>void) {
+ this.send({command:'getTrack', track:track});
+ this.cddaCallback = callback;
+ }
+
+ private initWorker() {
+ this.worker = new Worker('imagereader-worker.js');
+ this.worker.addEventListener('message', this.onMessage.bind(this));
+ this.worker.addEventListener('error', this.onWorkerError.bind(this));
+ }
+
+ private setReadyState(imgName:string, cueName:string) {
+ if (imgName) {
+ $('#imgReady').classList.remove('notready');
+ $('#imgReady code').textContent = imgName;
+ }
+ if (cueName) {
+ $('#cueReady').classList.remove('notready');
+ $('#cueReady code').textContent = cueName;
+ }
+ }
+
+ private populateFiles(files:any, volumeLabel:string) {
+ for (var name in files) {
+ var data: ArrayBufferView = new Uint8Array(files[name]);
+ FS.writeFile(name, data, { encoding: 'binary' });
+ }
+ Module.callMain();
+ }
+
+ private handleFileSelect(evt:Event) {
+ var input = evt.target;
+ var files = input.files;
+ for (var i = 0; i < files.length; i++)
+ this.setFile(files[i]);
+ input.value = '';
+ }
+
+ private handleDragOver(evt:DragEvent) {
+ evt.stopPropagation();
+ evt.preventDefault();
+ evt.dataTransfer.dropEffect = 'copy';
+ }
+
+ private handleDrop(evt:DragEvent) {
+ evt.stopPropagation();
+ evt.preventDefault();
+ var files = evt.dataTransfer.files;
+ for (var i = 0; i < files.length; i++)
+ this.setFile(files[i]);
+ }
+
+ private setFile(file:File) {
+ this.send({command:'setFile', file:file});
+ }
+
+ private startInstall() {
+ this.send({command:'extractFiles'});
+ }
+
+ private send(msg:any) {
+ this.worker.postMessage(msg);
+ }
+
+ private onMessage(evt: MessageEvent) {
+ switch (evt.data.command) {
+ case 'readyState':
+ this.setReadyState(evt.data.img, evt.data.cue);
+ if (evt.data.img && evt.data.cue)
+ this.startInstall();
+ break;
+ case 'extractFiles':
+ this.populateFiles(evt.data.files, evt.data.volumeLabel);
+ break;
+ case 'complete':
+ this.cddaCallback(evt.data.wav);
+ this.cddaCallback = null;
+ break;
+ case 'error':
+ console.log(evt.data.message);
+ break;
+ }
+ }
+
+ private onWorkerError(evt: Event) {
+ console.log('worker error', evt);
+ }
+}
+
+
+var shell = new Sys3Shell();
diff --git a/ts/tsconfig.json b/ts/tsconfig.json
new file mode 100644
index 0000000..2303da6
--- /dev/null
+++ b/ts/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es5",
+ "outDir": "../docs",
+ "noImplicitAny": true,
+ "sourceMap": false
+ }
+}
\ No newline at end of file