From 03fa65553252d9daba27802ac77a6d05d9588bd7 Mon Sep 17 00:00:00 2001 From: kichikuou Date: Sat, 4 Mar 2017 16:41:13 +0900 Subject: [PATCH] Add shell --- Makefile.emscripten | 2 +- docs/xsystem35.html | 61 +++++++ ts-worker/cdimage.ts | 280 ++++++++++++++++++++++++++++++++ ts-worker/imagereader-worker.ts | 100 ++++++++++++ ts-worker/package.json | 8 + ts-worker/tsconfig.json | 10 ++ ts/package.json | 8 + ts/shell.ts | 199 +++++++++++++++++++++++ ts/tsconfig.json | 9 + 9 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 docs/xsystem35.html create mode 100644 ts-worker/cdimage.ts create mode 100644 ts-worker/imagereader-worker.ts create mode 100644 ts-worker/package.json create mode 100644 ts-worker/tsconfig.json create mode 100644 ts/package.json create mode 100644 ts/shell.ts create mode 100644 ts/tsconfig.json 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