diff --git a/public/emu/TetrisGYM-6.0.0.bps b/public/emu/TetrisGYM-6.0.0.bps new file mode 100644 index 00000000..9107f9cf Binary files /dev/null and b/public/emu/TetrisGYM-6.0.0.bps differ diff --git a/public/emu/addresses.js b/public/emu/addresses.js new file mode 100644 index 00000000..5be6d50b --- /dev/null +++ b/public/emu/addresses.js @@ -0,0 +1,101 @@ +// offsets extracted from +// https://github.com/kirjavascript/TetrisGYM/blob/master/src/ram.asm +// and +// https://github.com/zohassadar/TetrisGYM/blob/ed2ntc/src/nmi/ed2ntc.asm +// id to [address, num_bytes] +const gym6_data_maps = { + gameMode: [0xc0, 1], // gameMode + playState: [0x48, 1], // gameModeState + completedRowXClear: [0x52, 1], // rowY (rowY is a terrible name in Rom) + completedRows: [0x4a, 4], // completedRow + lines: [0x50, 2], // lines + level: [0x44, 1], // levelNumber + score: [0x8, 4], // binScore + nextPieceOrientation: [0xbf, 1], // nextPiece + tetriminoOrientation: [0x42, 1], // currentPiece + tetriminoX: [0x40, 1], // tetriminoX + tetriminoY: [0x41, 1], // tetriminoY + frameCounter: [0xb1, 2], // frameCounter + autoRepeatX: [0x46, 1], // autorepeatX + stats: [0x3f0, 7 * 2], // statsByType + field: [0x400, 200], // playfield +}; + +export const address_maps = { + gym6: gym6_data_maps, +}; + +// The 2 functions below work because Javascript guarantees iteration order for objects +export function getDataAddresses(definition) { + const values = Object.values(definition); + const res = new Uint16Array(values.length * 2); + + values.forEach(([addr, size], idx) => { + res[idx * 2] = addr; + res[idx * 2 + 1] = size; + }); + + return res; +} + +export function assignData(rawData, definition) { + const entries = Object.entries(definition); + const result = { ...definition }; + + // raw assignments first + let offset = 0; + entries.forEach(([key, [_addr, size]]) => { + if (size === 1) { + result[key] = rawData[offset]; + } else { + result[key] = rawData.slice(offset, offset + size); + } + offset += size; + }); + + // data transformation + result.score = + (result.score[3] << 24) | + (result.score[2] << 16) | + (result.score[1] << 8) | + result.score[0]; + result.frameCounter = (result.frameCounter[1] << 8) | result.frameCounter[0]; + result.lines = _bcdToDecimal(result.lines[0], result.lines[1]); + + let statsByteIndex = 0; + result.T = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.J = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.Z = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.O = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.S = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.L = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + result.I = _bcdToDecimal( + result.stats[statsByteIndex++], + result.stats[statsByteIndex++] + ); + delete result.stats; + + result.field = result.field.map( + tileId => TILE_ID_TO_NTC_BLOCK_ID.get(tileId) ?? tileId + ); + + return result; +} diff --git a/public/emu/audio_processor.js b/public/emu/audio_processor.js new file mode 100644 index 00000000..7a01269d --- /dev/null +++ b/public/emu/audio_processor.js @@ -0,0 +1,54 @@ +// An example thing, obviously fix this +class NesAudioProcessor extends AudioWorkletProcessor { + constructor(...args) { + super(...args); + this.sampleBuffer = new Int16Array(0); + this.lastPlayedSample = 0; + this.port.onmessage = e => { + if (e.data.type == 'samples') { + let mergedBuffer = new Int16Array( + this.sampleBuffer.length + e.data.samples.length + ); + mergedBuffer.set(this.sampleBuffer); + mergedBuffer.set(e.data.samples, this.sampleBuffer.length); + this.sampleBuffer = mergedBuffer; + } + }; + } + process(inputs, outputs, parameters) { + const output = outputs[0]; + const desired_length = output[0].length; + //console.log("Want to play: ", desired_length); + //console.log("Actual size: ", this.sampleBuffer.length); + if (desired_length <= this.sampleBuffer.length) { + // Queue up the buffer contents. Note that NES audio is in mono, so we'll replicate that + // to every channel on the output. (I'm guessing usually 2?) + output.forEach(channel => { + for (let i = 0; i < channel.length; i++) { + // Convert from i16 to float, ranging from -1 to 1 + channel[i] = this.sampleBuffer[i] / 32768; + } + }); + // Set the new last played sample, this will be our hold value if we have an underrun + this.lastPlayedSample = this.sampleBuffer[desired_length - 1]; + // Remove those contents from the buffer + this.sampleBuffer = this.sampleBuffer.slice(desired_length); + // Finally, tell the main thread so it can adjust its totals + this.port.postMessage({ type: 'samplesPlayed', count: desired_length }); + } else { + // Queue up nothing! Specifically, *repeat* the last sample, to hold the level; this won't + // avoid a break in the audio, but it avoids ugly pops + output.forEach(channel => { + for (let i = 0; i < channel.length; i++) { + channel[i] = this.lastPlayedSample / 37268; + } + }); + // Tell the main thread that we've run behind + this.port.postMessage({ type: 'audioUnderrun', count: output[0].length }); + } + + return true; + } +} + +registerProcessor('nes-audio-processor', NesAudioProcessor); diff --git a/public/emu/bps.js b/public/emu/bps.js new file mode 100644 index 00000000..f744d4cd --- /dev/null +++ b/public/emu/bps.js @@ -0,0 +1,1381 @@ +/* FileSaver.js (source: http://purl.eligrey.com/github/FileSaver.js/blob/master/src/FileSaver.js) + * A saveAs() FileSaver implementation. + * 1.3.8 + * 2018-03-22 14:03:47 + * + * By Eli Grey, https://eligrey.com + * License: MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/* eslint-disable */ +var saveAs = + saveAs || + (function (c) { + 'use strict'; + if ( + !( + void 0 === c || + ('undefined' != typeof navigator && + /MSIE [1-9]\./.test(navigator.userAgent)) + ) + ) { + var t = c.document, + f = function () { + return c.URL || c.webkitURL || c; + }, + s = t.createElementNS('http://www.w3.org/1999/xhtml', 'a'), + d = 'download' in s, + u = /constructor/i.test(c.HTMLElement) || c.safari, + l = /CriOS\/[\d]+/.test(navigator.userAgent), + p = c.setImmediate || c.setTimeout, + v = function (t) { + p(function () { + throw t; + }, 0); + }, + w = function (t) { + setTimeout(function () { + 'string' == typeof t ? f().revokeObjectURL(t) : t.remove(); + }, 4e4); + }, + m = function (t) { + return /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test( + t.type + ) + ? new Blob([String.fromCharCode(65279), t], { + type: t.type, + }) + : t; + }, + r = function (t, n, e) { + e || (t = m(t)); + var r, + o = this, + a = 'application/octet-stream' === t.type, + i = function () { + !(function (t, e, n) { + for (var r = (e = [].concat(e)).length; r--; ) { + var o = t['on' + e[r]]; + if ('function' == typeof o) + try { + o.call(t, n || t); + } catch (t) { + v(t); + } + } + })(o, 'writestart progress write writeend'.split(' ')); + }; + if (((o.readyState = o.INIT), d)) + return ( + (r = f().createObjectURL(t)), + void p(function () { + var t, e; + (s.href = r), + (s.download = n), + (t = s), + (e = new MouseEvent('click')), + t.dispatchEvent(e), + i(), + w(r), + (o.readyState = o.DONE); + }, 0) + ); + !(function () { + if ((l || (a && u)) && c.FileReader) { + var e = new FileReader(); + return ( + (e.onloadend = function () { + var t = l + ? e.result + : e.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); + c.open(t, '_blank') || (c.location.href = t), + (t = void 0), + (o.readyState = o.DONE), + i(); + }), + e.readAsDataURL(t), + (o.readyState = o.INIT) + ); + } + r || (r = f().createObjectURL(t)), + a + ? (c.location.href = r) + : c.open(r, '_blank') || (c.location.href = r); + (o.readyState = o.DONE), i(), w(r); + })(); + }, + e = r.prototype; + return 'undefined' != typeof navigator && navigator.msSaveOrOpenBlob + ? function (t, e, n) { + return ( + (e = e || t.name || 'download'), + n || (t = m(t)), + navigator.msSaveOrOpenBlob(t, e) + ); + } + : ((e.abort = function () {}), + (e.readyState = e.INIT = 0), + (e.WRITING = 1), + (e.DONE = 2), + (e.error = + e.onwritestart = + e.onprogress = + e.onwrite = + e.onabort = + e.onerror = + e.onwriteend = + null), + function (t, e, n) { + return new r(t, e || t.name || 'download', n); + }); + } + })( + ('undefined' != typeof self && self) || + ('undefined' != typeof window && window) || + this + ); + +/* Rom Patcher JS - CRC32/MD5/SHA-1/checksums calculators v20210815 - Marc Robledo 2016-2021 - http://www.marcrobledo.com/license */ + +function padZeroes(intVal, nBytes) { + var hexString = intVal.toString(16); + while (hexString.length < nBytes * 2) hexString = '0' + hexString; + return hexString; +} + +/* SHA-1 using WebCryptoAPI */ +function _sha1_promise(hash) { + var bytes = new Uint8Array(hash); + var hexString = ''; + for (var i = 0; i < bytes.length; i++) hexString += padZeroes(bytes[i], 1); + el('sha1').innerHTML = hexString; +} +function sha1(marcFile) { + window.crypto.subtle + .digest('SHA-1', marcFile._u8array.buffer) + .then(_sha1_promise) + .catch(function (error) { + el('sha1').innerHTML = 'Error'; + }); +} + +/* MD5 - from Joseph's Myers - http://www.myersdaily.org/joseph/javascript/md5.js */ +const HEX_CHR = '0123456789abcdef'.split(''); +function _add32(a, b) { + return (a + b) & 0xffffffff; +} +function _md5cycle(x, k) { + var a = x[0], + b = x[1], + c = x[2], + d = x[3]; + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + x[0] = _add32(a, x[0]); + x[1] = _add32(b, x[1]); + x[2] = _add32(c, x[2]); + x[3] = _add32(d, x[3]); +} +function _md5blk(d) { + var md5blks = [], + i; + for (i = 0; i < 64; i += 4) + md5blks[i >> 2] = + d[i] + (d[i + 1] << 8) + (d[i + 2] << 16) + (d[i + 3] << 24); + return md5blks; +} +function _cmn(q, a, b, x, s, t) { + a = _add32(_add32(a, q), _add32(x, t)); + return _add32((a << s) | (a >>> (32 - s)), b); +} +function ff(a, b, c, d, x, s, t) { + return _cmn((b & c) | (~b & d), a, b, x, s, t); +} +function gg(a, b, c, d, x, s, t) { + return _cmn((b & d) | (c & ~d), a, b, x, s, t); +} +function hh(a, b, c, d, x, s, t) { + return _cmn(b ^ c ^ d, a, b, x, s, t); +} +function ii(a, b, c, d, x, s, t) { + return _cmn(c ^ (b | ~d), a, b, x, s, t); +} +function md5(data) { + // Uint8Array + // var data=headerSize? new Uint8Array(marcFile._u8array.buffer, headerSize):marcFile._u8array; + + var n = data.length, + state = [1732584193, -271733879, -1732584194, 271733878], + i; + for (i = 64; i <= data.length; i += 64) + _md5cycle(state, _md5blk(data.slice(i - 64, i))); + data = data.slice(i - 64); + var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (i = 0; i < data.length; i++) tail[i >> 2] |= data[i] << (i % 4 << 3); + tail[i >> 2] |= 0x80 << (i % 4 << 3); + if (i > 55) { + _md5cycle(state, tail); + for (i = 0; i < 16; i++) tail[i] = 0; + } + tail[14] = n * 8; + tail[15] = Math.floor(n / 536870912) >>> 0; //if file is bigger than 512Mb*8, value is bigger than 32 bits, so it needs two words to store its length + _md5cycle(state, tail); + + for (var i = 0; i < state.length; i++) { + var s = '', + j = 0; + for (; j < 4; j++) + s += + HEX_CHR[(state[i] >> (j * 8 + 4)) & 0x0f] + + HEX_CHR[(state[i] >> (j * 8)) & 0x0f]; + state[i] = s; + } + return state.join(''); +} + +/* CRC32 - from Alex - https://stackoverflow.com/a/18639999 */ +const CRC32_TABLE = (function () { + var c, + crcTable = []; + for (var n = 0; n < 256; n++) { + c = n; + for (var k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crcTable[n] = c; + } + return crcTable; +})(); +function crc32(marcFile, headerSize, ignoreLast4Bytes) { + var data = headerSize + ? new Uint8Array(marcFile._u8array.buffer, headerSize) + : marcFile._u8array; + + var crc = 0 ^ -1; + + var len = ignoreLast4Bytes ? data.length - 4 : data.length; + for (var i = 0; i < len; i++) + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xff]; + + return (crc ^ -1) >>> 0; +} + +/* Adler-32 - https://en.wikipedia.org/wiki/Adler-32#Example_implementation */ +const ADLER32_MOD = 0xfff1; +function adler32(marcFile, offset, len) { + var a = 1, + b = 0; + + for (var i = 0; i < len; i++) { + a = (a + marcFile._u8array[i + offset]) % ADLER32_MOD; + b = (b + a) % ADLER32_MOD; + } + + return ((b << 16) | a) >>> 0; +} + +/* CRC16/CCITT-FALSE */ +function crc16(marcFile, offset, len) { + var crc = 0xffff; + + offset = offset ? offset : 0; + len = len && len > 0 ? len : marcFile.fileSize; + + for (var i = 0; i < len; i++) { + crc ^= marcFile._u8array[offset++] << 8; + for (j = 0; j < 8; ++j) { + crc = (crc & 0x8000) >>> 0 ? (crc << 1) ^ 0x1021 : crc << 1; + } + } + + return crc & 0xffff; +} + +/* BPS module for Rom Patcher JS v20180930 - Marc Robledo 2016-2018 - http://www.marcrobledo.com/license */ +/* File format specification: https://www.romhacking.net/documents/746/ */ + +const BPS_MAGIC = 'BPS1'; +const BPS_ACTION_SOURCE_READ = 0; +const BPS_ACTION_TARGET_READ = 1; +const BPS_ACTION_SOURCE_COPY = 2; +const BPS_ACTION_TARGET_COPY = 3; + +// TODO rewrite as class +export function BPS() { + this.sourceSize = 0; + this.targetSize = 0; + this.metaData = ''; + this.actions = []; + this.sourceChecksum = 0; + this.targetChecksum = 0; + this.patchChecksum = 0; +} +BPS.prototype.toString = function () { + var s = 'Source size: ' + this.sourceSize; + s += '\nTarget size: ' + this.targetSize; + s += '\nMetadata: ' + this.metaData; + s += '\n#Actions: ' + this.actions.length; + return s; +}; +BPS.prototype.validateSource = function (romFile, headerSize) { + return this.sourceChecksum === crc32(romFile, headerSize); +}; +BPS.prototype.apply = function (romFile, validate) { + if (validate && !this.validateSource(romFile)) { + throw new Error('error_crc_input'); + } + + const tempFile = new MarcFile(this.targetSize); + + //patch + var sourceRelativeOffset = 0; + var targetRelativeOffset = 0; + for (var i = 0; i < this.actions.length; i++) { + var action = this.actions[i]; + + if (action.type === BPS_ACTION_SOURCE_READ) { + romFile.copyToFile(tempFile, tempFile.offset, action.length); + tempFile.skip(action.length); + } else if (action.type === BPS_ACTION_TARGET_READ) { + tempFile.writeBytes(action.bytes); + } else if (action.type === BPS_ACTION_SOURCE_COPY) { + sourceRelativeOffset += action.relativeOffset; + var actionLength = action.length; + while (actionLength--) { + tempFile.writeU8(romFile._u8array[sourceRelativeOffset]); + sourceRelativeOffset++; + } + } else if (action.type === BPS_ACTION_TARGET_COPY) { + targetRelativeOffset += action.relativeOffset; + var actionLength = action.length; + while (actionLength--) { + tempFile.writeU8(tempFile._u8array[targetRelativeOffset]); + targetRelativeOffset++; + } + } + } + + if (validate && this.targetChecksum !== crc32(tempFile)) { + throw new Error('error_crc_output'); + } + + return tempFile; +}; + +export function parseBPSFile(file) { + file.readVLV = BPS_readVLV; + + file.littleEndian = true; + var patch = new BPS(); + + file.seek(4); //skip BPS1 + + patch.sourceSize = file.readVLV(); + patch.targetSize = file.readVLV(); + + var metaDataLength = file.readVLV(); + if (metaDataLength) { + patch.metaData = file.readString(metaDataLength); + } + + var endActionsOffset = file.fileSize - 12; + while (file.offset < endActionsOffset) { + var data = file.readVLV(); + var action = { type: data & 3, length: (data >> 2) + 1 }; + + if (action.type === BPS_ACTION_TARGET_READ) { + action.bytes = file.readBytes(action.length); + } else if ( + action.type === BPS_ACTION_SOURCE_COPY || + action.type === BPS_ACTION_TARGET_COPY + ) { + var relativeOffset = file.readVLV(); + action.relativeOffset = + (relativeOffset & 1 ? -1 : +1) * (relativeOffset >> 1); + } + + patch.actions.push(action); + } + + //file.seek(endActionsOffset); + patch.sourceChecksum = file.readU32(); + patch.targetChecksum = file.readU32(); + patch.patchChecksum = file.readU32(); + + if (patch.patchChecksum !== crc32(file, 0, true)) { + throw new Error('error_crc_patch'); + } + + return patch; +} + +function BPS_readVLV() { + var data = 0, + shift = 1; + while (true) { + var x = this.readU8(); + data += (x & 0x7f) * shift; + if (x & 0x80) break; + shift <<= 7; + data += shift; + } + + this._lastRead = data; + return data; +} +function BPS_writeVLV(data) { + while (true) { + var x = data & 0x7f; + data >>= 7; + if (data === 0) { + this.writeU8(0x80 | x); + break; + } + this.writeU8(x); + data--; + } +} +function BPS_getVLVLen(data) { + var len = 0; + while (true) { + var x = data & 0x7f; + data >>= 7; + if (data === 0) { + len++; + break; + } + len++; + data--; + } + return len; +} + +BPS.prototype.export = function (fileName) { + var patchFileSize = BPS_MAGIC.length; + patchFileSize += BPS_getVLVLen(this.sourceSize); + patchFileSize += BPS_getVLVLen(this.targetSize); + patchFileSize += BPS_getVLVLen(this.metaData.length); + patchFileSize += this.metaData.length; + for (var i = 0; i < this.actions.length; i++) { + var action = this.actions[i]; + patchFileSize += BPS_getVLVLen(((action.length - 1) << 2) + action.type); + + if (action.type === BPS_ACTION_TARGET_READ) { + patchFileSize += action.length; + } else if ( + action.type === BPS_ACTION_SOURCE_COPY || + action.type === BPS_ACTION_TARGET_COPY + ) { + patchFileSize += BPS_getVLVLen( + (Math.abs(action.relativeOffset) << 1) + + (action.relativeOffset < 0 ? 1 : 0) + ); + } + } + patchFileSize += 12; + + var patchFile = new MarcFile(patchFileSize); + patchFile.fileName = fileName + '.bps'; + patchFile.littleEndian = true; + patchFile.writeVLV = BPS_writeVLV; + + patchFile.writeString(BPS_MAGIC); + patchFile.writeVLV(this.sourceSize); + patchFile.writeVLV(this.targetSize); + patchFile.writeVLV(this.metaData.length); + patchFile.writeString(this.metaData, this.metaData.length); + + for (var i = 0; i < this.actions.length; i++) { + var action = this.actions[i]; + patchFile.writeVLV(((action.length - 1) << 2) + action.type); + + if (action.type === BPS_ACTION_TARGET_READ) { + patchFile.writeBytes(action.bytes); + } else if ( + action.type === BPS_ACTION_SOURCE_COPY || + action.type === BPS_ACTION_TARGET_COPY + ) { + patchFile.writeVLV( + (Math.abs(action.relativeOffset) << 1) + + (action.relativeOffset < 0 ? 1 : 0) + ); + } + } + patchFile.writeU32(this.sourceChecksum); + patchFile.writeU32(this.targetChecksum); + patchFile.writeU32(this.patchChecksum); + + return patchFile; +}; + +function BPS_Node() { + this.offset = 0; + this.next = null; +} +BPS_Node.prototype.delete = function () { + if (this.next) delete this.next; +}; +function createBPSFromFiles(original, modified, deltaMode) { + var patch = new BPS(); + patch.sourceSize = original.fileSize; + patch.targetSize = modified.fileSize; + + if (deltaMode) { + patch.actions = createBPSFromFilesDelta(original, modified); + } else { + patch.actions = createBPSFromFilesLinear(original, modified); + } + + patch.sourceChecksum = crc32(original); + patch.targetChecksum = crc32(modified); + patch.patchChecksum = crc32(patch.export(), 0, true); + return patch; +} + +/* delta implementation from https://github.com/chiya/beat/blob/master/nall/beat/linear.hpp */ +function createBPSFromFilesLinear(original, modified) { + var patchActions = []; + + /* references to match original beat code */ + var sourceData = original._u8array; + var targetData = modified._u8array; + var sourceSize = original.fileSize; + var targetSize = modified.fileSize; + var Granularity = 1; + + var targetRelativeOffset = 0; + var outputOffset = 0; + var targetReadLength = 0; + + function targetReadFlush() { + if (targetReadLength) { + //encode(TargetRead | ((targetReadLength - 1) << 2)); + var action = { + type: BPS_ACTION_TARGET_READ, + length: targetReadLength, + bytes: [], + }; + patchActions.push(action); + var offset = outputOffset - targetReadLength; + while (targetReadLength) { + //write(targetData[offset++]); + action.bytes.push(targetData[offset++]); + targetReadLength--; + } + } + } + + while (outputOffset < targetSize) { + var sourceLength = 0; + for (var n = 0; outputOffset + n < Math.min(sourceSize, targetSize); n++) { + if (sourceData[outputOffset + n] != targetData[outputOffset + n]) break; + sourceLength++; + } + + var rleLength = 0; + for (var n = 1; outputOffset + n < targetSize; n++) { + if (targetData[outputOffset] != targetData[outputOffset + n]) break; + rleLength++; + } + + if (rleLength >= 4) { + //write byte to repeat + targetReadLength++; + outputOffset++; + targetReadFlush(); + + //copy starting from repetition byte + //encode(TargetCopy | ((rleLength - 1) << 2)); + var relativeOffset = outputOffset - 1 - targetRelativeOffset; + //encode(relativeOffset << 1); + patchActions.push({ + type: BPS_ACTION_TARGET_COPY, + length: rleLength, + relativeOffset: relativeOffset, + }); + outputOffset += rleLength; + targetRelativeOffset = outputOffset - 1; + } else if (sourceLength >= 4) { + targetReadFlush(); + //encode(SourceRead | ((sourceLength - 1) << 2)); + patchActions.push({ + type: BPS_ACTION_SOURCE_READ, + length: sourceLength, + }); + outputOffset += sourceLength; + } else { + targetReadLength += Granularity; + outputOffset += Granularity; + } + } + + targetReadFlush(); + + return patchActions; +} + +/* delta implementation from https://github.com/chiya/beat/blob/master/nall/beat/delta.hpp */ +function createBPSFromFilesDelta(original, modified) { + var patchActions = []; + + /* references to match original beat code */ + var sourceData = original._u8array; + var targetData = modified._u8array; + var sourceSize = original.fileSize; + var targetSize = modified.fileSize; + var Granularity = 1; + + var sourceRelativeOffset = 0; + var targetRelativeOffset = 0; + var outputOffset = 0; + + var sourceTree = new Array(65536); + var targetTree = new Array(65536); + for (var n = 0; n < 65536; n++) { + sourceTree[n] = null; + targetTree[n] = null; + } + + //source tree creation + for (var offset = 0; offset < sourceSize; offset++) { + var symbol = sourceData[offset + 0]; + //sourceChecksum = crc32_adjust(sourceChecksum, symbol); + if (offset < sourceSize - 1) symbol |= sourceData[offset + 1] << 8; + var node = new BPS_Node(); + node.offset = offset; + node.next = sourceTree[symbol]; + sourceTree[symbol] = node; + } + + var targetReadLength = 0; + + function targetReadFlush() { + if (targetReadLength) { + //encode(TargetRead | ((targetReadLength - 1) << 2)); + var action = { + type: BPS_ACTION_TARGET_READ, + length: targetReadLength, + bytes: [], + }; + patchActions.push(action); + var offset = outputOffset - targetReadLength; + while (targetReadLength) { + //write(targetData[offset++]); + action.bytes.push(targetData[offset++]); + targetReadLength--; + } + } + } + + while (outputOffset < modified.fileSize) { + var maxLength = 0, + maxOffset = 0, + mode = BPS_ACTION_TARGET_READ; + + var symbol = targetData[outputOffset + 0]; + if (outputOffset < targetSize - 1) + symbol |= targetData[outputOffset + 1] << 8; + + { + //source read + var length = 0, + offset = outputOffset; + while ( + offset < sourceSize && + offset < targetSize && + sourceData[offset] == targetData[offset] + ) { + length++; + offset++; + } + if (length > maxLength) + (maxLength = length), (mode = BPS_ACTION_SOURCE_READ); + } + + { + //source copy + var node = sourceTree[symbol]; + while (node) { + var length = 0, + x = node.offset, + y = outputOffset; + while ( + x < sourceSize && + y < targetSize && + sourceData[x++] == targetData[y++] + ) + length++; + if (length > maxLength) + (maxLength = length), + (maxOffset = node.offset), + (mode = BPS_ACTION_SOURCE_COPY); + node = node.next; + } + } + + { + //target copy + var node = targetTree[symbol]; + while (node) { + var length = 0, + x = node.offset, + y = outputOffset; + while (y < targetSize && targetData[x++] == targetData[y++]) length++; + if (length > maxLength) + (maxLength = length), + (maxOffset = node.offset), + (mode = BPS_ACTION_TARGET_COPY); + node = node.next; + } + + //target tree append + node = new BPS_Node(); + node.offset = outputOffset; + node.next = targetTree[symbol]; + targetTree[symbol] = node; + } + + { + //target read + if (maxLength < 4) { + maxLength = Math.min(Granularity, targetSize - outputOffset); + mode = BPS_ACTION_TARGET_READ; + } + } + + if (mode != BPS_ACTION_TARGET_READ) targetReadFlush(); + + switch (mode) { + case BPS_ACTION_SOURCE_READ: + //encode(BPS_ACTION_SOURCE_READ | ((maxLength - 1) << 2)); + patchActions.push({ + type: BPS_ACTION_SOURCE_READ, + length: maxLength, + }); + break; + case BPS_ACTION_TARGET_READ: + //delay write to group sequential TargetRead commands into one + targetReadLength += maxLength; + break; + case BPS_ACTION_SOURCE_COPY: + case BPS_ACTION_TARGET_COPY: + //encode(mode | ((maxLength - 1) << 2)); + var relativeOffset; + if (mode == BPS_ACTION_SOURCE_COPY) { + relativeOffset = maxOffset - sourceRelativeOffset; + sourceRelativeOffset = maxOffset + maxLength; + } else { + relativeOffset = maxOffset - targetRelativeOffset; + targetRelativeOffset = maxOffset + maxLength; + } + //encode((relativeOffset < 0) | (abs(relativeOffset) << 1)); + patchActions.push({ + type: mode, + length: maxLength, + relativeOffset: relativeOffset, + }); + break; + } + + outputOffset += maxLength; + } + + targetReadFlush(); + + return patchActions; +} + +/* MODDED VERSION OF MarcFile.js v20230202 - Marc Robledo 2014-2023 - http://www.marcrobledo.com/license */ + +export function MarcFile(source, onLoad) { + if (typeof source === 'object' && source.files) + /* get first file only if source is input with multiple files */ + source = source.files[0]; + + this.littleEndian = false; + this.offset = 0; + this._lastRead = null; + + if (typeof source === 'object' && source.name && source.size) { + /* source is file */ + if (typeof window.FileReader !== 'function') + throw new Error('Incompatible Browser'); + + this.fileName = source.name; + this.fileType = source.type; + this.fileSize = source.size; + + this._fileReader = new FileReader(); + this._fileReader.marcFile = this; + this._fileReader.addEventListener( + 'load', + function () { + this.marcFile._u8array = new Uint8Array(this.result); + this.marcFile._dataView = new DataView(this.result); + + if (onLoad) onLoad.call(); + }, + false + ); + + this._fileReader.readAsArrayBuffer(source); + } else if ( + typeof source === 'object' && + typeof source.fileName === 'string' && + typeof source.littleEndian === 'boolean' + ) { + /* source is MarcFile */ + this.fileName = source.fileName; + this.fileType = source.fileType; + this.fileSize = source.fileSize; + + var ab = new ArrayBuffer(source); + this._u8array = new Uint8Array(this.fileType); + this._dataView = new DataView(this.fileType); + + source.copyToFile(this, 0); + if (onLoad) onLoad.call(); + } else if ( + typeof source === 'object' && + typeof source.byteLength === 'number' + ) { + /* source is ArrayBuffer or TypedArray */ + this.fileName = 'file.bin'; + this.fileType = 'application/octet-stream'; + this.fileSize = source.byteLength; + + if (typeof source.buffer !== 'undefined') source = source.buffer; + this._u8array = new Uint8Array(source); + this._dataView = new DataView(source); + + if (onLoad) onLoad.call(); + } else if (typeof source === 'number') { + /* source is integer (new empty file) */ + this.fileName = 'file.bin'; + this.fileType = 'application/octet-stream'; + this.fileSize = source; + + var ab = new ArrayBuffer(source); + this._u8array = new Uint8Array(ab); + this._dataView = new DataView(ab); + + if (onLoad) onLoad.call(); + } else { + throw new Error('Invalid source'); + } +} +MarcFile.IS_MACHINE_LITTLE_ENDIAN = (function () { + /* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView#Endianness */ + var buffer = new ArrayBuffer(2); + new DataView(buffer).setInt16(0, 256, true /* littleEndian */); + // Int16Array uses the platform's endianness. + return new Int16Array(buffer)[0] === 256; +})(); + +MarcFile.prototype.seek = function (offset) { + this.offset = offset; +}; +MarcFile.prototype.skip = function (nBytes) { + this.offset += nBytes; +}; +MarcFile.prototype.isEOF = function () { + return !(this.offset < this.fileSize); +}; + +MarcFile.prototype.slice = function (offset, len) { + len = len || this.fileSize - offset; + + var newFile; + + if (typeof this._u8array.buffer.slice !== 'undefined') { + newFile = new MarcFile(0); + newFile.fileSize = len; + newFile._u8array = new Uint8Array( + this._u8array.buffer.slice(offset, offset + len) + ); + } else { + newFile = new MarcFile(len); + this.copyToFile(newFile, offset, len, 0); + } + newFile.fileName = this.fileName; + newFile.fileType = this.fileType; + newFile.littleEndian = this.littleEndian; + return newFile; +}; + +MarcFile.prototype.copyToFile = function ( + target, + offsetSource, + len, + offsetTarget +) { + if (typeof offsetTarget === 'undefined') offsetTarget = offsetSource; + + len = len || this.fileSize - offsetSource; + + for (var i = 0; i < len; i++) { + target._u8array[offsetTarget + i] = this._u8array[offsetSource + i]; + } +}; + +MarcFile.prototype.save = function () { + var blob; + try { + blob = new Blob([this._u8array], { type: this.fileType }); + } catch (e) { + //old browser, use BlobBuilder + window.BlobBuilder = + window.BlobBuilder || + window.WebKitBlobBuilder || + window.MozBlobBuilder || + window.MSBlobBuilder; + if (e.name === 'InvalidStateError' && window.BlobBuilder) { + var bb = new BlobBuilder(); + bb.append(this._u8array.buffer); + blob = bb.getBlob(this.fileType); + } else { + throw new Error('Incompatible Browser'); + return false; + } + } + saveAs(blob, this.fileName); +}; + +MarcFile.prototype.getExtension = function () { + var ext = this.fileName ? this.fileName.toLowerCase().match(/\.(\w+)$/) : ''; + + return ext ? ext[1] : ''; +}; + +MarcFile.prototype.readU8 = function () { + this._lastRead = this._u8array[this.offset]; + + this.offset++; + return this._lastRead; +}; +MarcFile.prototype.readU16 = function () { + if (this.littleEndian) + this._lastRead = + this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8); + else + this._lastRead = + (this._u8array[this.offset] << 8) + this._u8array[this.offset + 1]; + + this.offset += 2; + return this._lastRead >>> 0; +}; +MarcFile.prototype.readU24 = function () { + if (this.littleEndian) + this._lastRead = + this._u8array[this.offset] + + (this._u8array[this.offset + 1] << 8) + + (this._u8array[this.offset + 2] << 16); + else + this._lastRead = + (this._u8array[this.offset] << 16) + + (this._u8array[this.offset + 1] << 8) + + this._u8array[this.offset + 2]; + + this.offset += 3; + return this._lastRead >>> 0; +}; +MarcFile.prototype.readU32 = function () { + if (this.littleEndian) + this._lastRead = + this._u8array[this.offset] + + (this._u8array[this.offset + 1] << 8) + + (this._u8array[this.offset + 2] << 16) + + (this._u8array[this.offset + 3] << 24); + else + this._lastRead = + (this._u8array[this.offset] << 24) + + (this._u8array[this.offset + 1] << 16) + + (this._u8array[this.offset + 2] << 8) + + this._u8array[this.offset + 3]; + + this.offset += 4; + return this._lastRead >>> 0; +}; + +MarcFile.prototype.readBytes = function (len) { + this._lastRead = new Array(len); + for (var i = 0; i < len; i++) { + this._lastRead[i] = this._u8array[this.offset + i]; + } + + this.offset += len; + return this._lastRead; +}; + +MarcFile.prototype.readString = function (len) { + this._lastRead = ''; + for ( + var i = 0; + i < len && + this.offset + i < this.fileSize && + this._u8array[this.offset + i] > 0; + i++ + ) + this._lastRead = + this._lastRead + String.fromCharCode(this._u8array[this.offset + i]); + + this.offset += len; + return this._lastRead; +}; + +MarcFile.prototype.writeU8 = function (u8) { + this._u8array[this.offset] = u8; + + this.offset++; +}; +MarcFile.prototype.writeU16 = function (u16) { + if (this.littleEndian) { + this._u8array[this.offset] = u16 & 0xff; + this._u8array[this.offset + 1] = u16 >> 8; + } else { + this._u8array[this.offset] = u16 >> 8; + this._u8array[this.offset + 1] = u16 & 0xff; + } + + this.offset += 2; +}; +MarcFile.prototype.writeU24 = function (u24) { + if (this.littleEndian) { + this._u8array[this.offset] = u24 & 0x0000ff; + this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8; + this._u8array[this.offset + 2] = (u24 & 0xff0000) >> 16; + } else { + this._u8array[this.offset] = (u24 & 0xff0000) >> 16; + this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8; + this._u8array[this.offset + 2] = u24 & 0x0000ff; + } + + this.offset += 3; +}; +MarcFile.prototype.writeU32 = function (u32) { + if (this.littleEndian) { + this._u8array[this.offset] = u32 & 0x000000ff; + this._u8array[this.offset + 1] = (u32 & 0x0000ff00) >> 8; + this._u8array[this.offset + 2] = (u32 & 0x00ff0000) >> 16; + this._u8array[this.offset + 3] = (u32 & 0xff000000) >> 24; + } else { + this._u8array[this.offset] = (u32 & 0xff000000) >> 24; + this._u8array[this.offset + 1] = (u32 & 0x00ff0000) >> 16; + this._u8array[this.offset + 2] = (u32 & 0x0000ff00) >> 8; + this._u8array[this.offset + 3] = u32 & 0x000000ff; + } + + this.offset += 4; +}; + +MarcFile.prototype.writeBytes = function (a) { + for (var i = 0; i < a.length; i++) this._u8array[this.offset + i] = a[i]; + + this.offset += a.length; +}; + +MarcFile.prototype.writeString = function (str, len) { + len = len || str.length; + for (var i = 0; i < str.length && i < len; i++) + this._u8array[this.offset + i] = str.charCodeAt(i); + + for (; i < len; i++) this._u8array[this.offset + i] = 0x00; + + this.offset += len; +}; + +/* IPS module for Rom Patcher JS v20220417 - Marc Robledo 2016-2022 - http://www.marcrobledo.com/license */ +/* File format specification: http://www.smwiki.net/wiki/IPS_file_format */ + +const IPS_MAGIC = 'PATCH'; +const IPS_MAX_SIZE = 0x1000000; //16 megabytes +const IPS_RECORD_RLE = 0x0000; +const IPS_RECORD_SIMPLE = 0x01; + +function IPS() { + this.records = []; + this.truncate = false; +} +IPS.prototype.addSimpleRecord = function (o, d) { + this.records.push({ + offset: o, + type: IPS_RECORD_SIMPLE, + length: d.length, + data: d, + }); +}; +IPS.prototype.addRLERecord = function (o, l, b) { + this.records.push({ offset: o, type: IPS_RECORD_RLE, length: l, byte: b }); +}; +IPS.prototype.toString = function () { + nSimpleRecords = 0; + nRLERecords = 0; + for (var i = 0; i < this.records.length; i++) { + if (this.records[i].type === IPS_RECORD_RLE) nRLERecords++; + else nSimpleRecords++; + } + var s = 'Simple records: ' + nSimpleRecords; + s += '\nRLE records: ' + nRLERecords; + s += '\nTotal records: ' + this.records.length; + if (this.truncate) s += '\nTruncate at: 0x' + this.truncate.toString(16); + return s; +}; +IPS.prototype.export = function (fileName) { + var patchFileSize = 5; //PATCH string + for (var i = 0; i < this.records.length; i++) { + if (this.records[i].type === IPS_RECORD_RLE) patchFileSize += 3 + 2 + 2 + 1; + //offset+0x0000+length+RLE byte to be written + else patchFileSize += 3 + 2 + this.records[i].data.length; //offset+length+data + } + patchFileSize += 3; //EOF string + if (this.truncate) patchFileSize += 3; //truncate + + tempFile = new MarcFile(patchFileSize); + tempFile.fileName = fileName + '.ips'; + tempFile.writeString(IPS_MAGIC); + for (var i = 0; i < this.records.length; i++) { + var rec = this.records[i]; + tempFile.writeU24(rec.offset); + if (rec.type === IPS_RECORD_RLE) { + tempFile.writeU16(0x0000); + tempFile.writeU16(rec.length); + tempFile.writeU8(rec.byte); + } else { + tempFile.writeU16(rec.data.length); + tempFile.writeBytes(rec.data); + } + } + + tempFile.writeString('EOF'); + if (this.truncate) tempFile.writeU24(this.truncate); + + return tempFile; +}; +IPS.prototype.apply = function (romFile) { + if (this.truncate) { + if (this.truncate > romFile.fileSize) { + //expand (discussed here: https://github.com/marcrobledo/RomPatcher.js/pull/46) + tempFile = new MarcFile(this.truncate); + romFile.copyToFile(tempFile, 0, romFile.fileSize, 0); + } else { + //truncate + tempFile = romFile.slice(0, this.truncate); + } + } else { + //calculate target ROM size, expanding it if any record offset is beyond target ROM size + var newFileSize = romFile.fileSize; + for (var i = 0; i < this.records.length; i++) { + var rec = this.records[i]; + if (rec.type === IPS_RECORD_RLE) { + if (rec.offset + rec.length > newFileSize) { + newFileSize = rec.offset + rec.length; + } + } else { + if (rec.offset + rec.data.length > newFileSize) { + newFileSize = rec.offset + rec.data.length; + } + } + } + + if (newFileSize === romFile.fileSize) { + tempFile = romFile.slice(0, romFile.fileSize); + } else { + tempFile = new MarcFile(newFileSize); + romFile.copyToFile(tempFile, 0); + } + } + + romFile.seek(0); + + for (var i = 0; i < this.records.length; i++) { + tempFile.seek(this.records[i].offset); + if (this.records[i].type === IPS_RECORD_RLE) { + for (var j = 0; j < this.records[i].length; j++) + tempFile.writeU8(this.records[i].byte); + } else { + tempFile.writeBytes(this.records[i].data); + } + } + + return tempFile; +}; + +function parseIPSFile(file) { + var patchFile = new IPS(); + file.seek(5); + + while (!file.isEOF()) { + var offset = file.readU24(); + + if (offset === 0x454f46) { + /* EOF */ + if (file.isEOF()) { + break; + } else if (file.offset + 3 === file.fileSize) { + patchFile.truncate = file.readU24(); + break; + } + } + + var length = file.readU16(); + + if (length === IPS_RECORD_RLE) { + patchFile.addRLERecord(offset, file.readU16(), file.readU8()); + } else { + patchFile.addSimpleRecord(offset, file.readBytes(length)); + } + } + return patchFile; +} + +function createIPSFromFiles(original, modified) { + var patch = new IPS(); + + if (modified.fileSize < original.fileSize) { + patch.truncate = modified.fileSize; + } + + //solucion: guardar startOffset y endOffset (ir mirando de 6 en 6 hacia atrĂ¡s) + var previousRecord = { type: 0xdeadbeef, startOffset: 0, length: 0 }; + while (!modified.isEOF()) { + var b1 = original.isEOF() ? 0x00 : original.readU8(); + var b2 = modified.readU8(); + + if (b1 !== b2) { + var RLEmode = true; + var differentData = []; + var startOffset = modified.offset - 1; + + while (b1 !== b2 && differentData.length < 0xffff) { + differentData.push(b2); + if (b2 !== differentData[0]) RLEmode = false; + + if (modified.isEOF() || differentData.length === 0xffff) break; + + b1 = original.isEOF() ? 0x00 : original.readU8(); + b2 = modified.readU8(); + } + + //check if this record is near the previous one + var distance = + startOffset - (previousRecord.offset + previousRecord.length); + if ( + previousRecord.type === IPS_RECORD_SIMPLE && + distance < 6 && + previousRecord.length + distance + differentData.length < 0xffff + ) { + if (RLEmode && differentData.length > 6) { + // separate a potential RLE record + original.seek(startOffset); + modified.seek(startOffset); + previousRecord = { + type: 0xdeadbeef, + startOffset: 0, + length: 0, + }; + } else { + // merge both records + while (distance--) { + previousRecord.data.push( + modified._u8array[previousRecord.offset + previousRecord.length] + ); + previousRecord.length++; + } + previousRecord.data = previousRecord.data.concat(differentData); + previousRecord.length = previousRecord.data.length; + } + } else { + if (startOffset >= IPS_MAX_SIZE) { + throw new Error('files are too big for IPS format'); + return null; + } + + if (RLEmode && differentData.length > 2) { + patch.addRLERecord( + startOffset, + differentData.length, + differentData[0] + ); + } else { + patch.addSimpleRecord(startOffset, differentData); + } + previousRecord = patch.records[patch.records.length - 1]; + } + } + } + + if (modified.fileSize > original.fileSize) { + var lastRecord = patch.records[patch.records.length - 1]; + var lastOffset = lastRecord.offset + lastRecord.length; + + if (lastOffset < modified.fileSize) { + patch.addSimpleRecord(modified.fileSize - 1, [0x00]); + } + } + + return patch; +} + +/* eslint-enable */ +// module.exports = { MarcFile, saveAs, md5, parseBPSFile, parseIPSFile }; diff --git a/public/emu/emu_worker.js b/public/emu/emu_worker.js new file mode 100644 index 00000000..3fd2a7be --- /dev/null +++ b/public/emu/emu_worker.js @@ -0,0 +1,189 @@ +// note: this needs the ram reader patch to work +// see forked branch at https://github.com/timotheeg/rustico/tree/memory_access +importScripts('./rustico_wasm.js'); + +const { + wasm_init, + load_rom, + run_until_vblank, + set_p1_input, + set_p2_input, + set_audio_samplerate, + set_audio_buffersize, + audio_buffer_full, + get_audio_buffer, + get_ram, + get_sram, + set_sram, + has_sram, + update_windows, + draw_piano_roll_window, + draw_screen_pixels, + piano_roll_window_click, + consume_audio_samples, +} = wasm_bindgen; + +let initialized = false; +let profiling = { + run_one_frame: { accumulated_time: 0, count: 0 }, + render_screen: { accumulated_time: 0, count: 0 }, + render_piano_roll: { accumulated_time: 0, count: 0 }, + idle: { accumulated_time: 0, count: 0 }, + render_all_panels: { accumulated_time: 0, count: 0 }, +}; +let idle_start = 0; +let idle_accumulator = 0; + +function collect_profiling(event_name, measured_time) { + let profile = profiling[event_name]; + profile.accumulated_time += measured_time; + profile.count += 1; + // do an average over 10 frames or so + if (profile.count >= 60) { + let average = profile.accumulated_time / 60; + profile.count = 0; + profile.accumulated_time = 0; + postMessage({ + type: 'reportPerformance', + event: event_name, + average_milliseconds: average, + }); + } +} + +// TODO: The rust side of this *should* be generating appropriate error +// messages. Can we catch those and propogate that error to the UI? That +// would be excellent for users, right now they're just getting silent +// failure. +function load_cartridge(cart_data) { + load_rom(cart_data); + set_audio_samplerate(44100); +} + +function run_one_frame() { + let start_time = performance.now(); + run_until_vblank(); + update_windows(); + collect_profiling('run_one_frame', performance.now() - start_time); +} + +function get_screen_pixels(dest_array_buffer) { + let start_time = performance.now(); + //let raw_buffer = new ArrayBuffer(256*240*4); + //let screen_pixels = new Uint8ClampedArray(raw_buffer); + let screen_pixels = new Uint8ClampedArray(dest_array_buffer); + draw_screen_pixels(screen_pixels); + collect_profiling('render_screen', performance.now() - start_time); + return dest_array_buffer; +} + +function get_piano_roll_pixels(dest_array_buffer) { + let start_time = performance.now(); + //let raw_buffer = new ArrayBuffer(480*270*4); + //let screen_pixels = new Uint8ClampedArray(raw_buffer); + let screen_pixels = new Uint8ClampedArray(dest_array_buffer); + draw_piano_roll_window(screen_pixels); + collect_profiling('render_piano_roll', performance.now() - start_time); + return dest_array_buffer; +} + +function handle_piano_roll_window_click(mx, my) { + piano_roll_window_click(mx, my); +} + +const rpc_functions = { + load_cartridge: load_cartridge, + run_one_frame: run_one_frame, + get_screen_pixels: get_screen_pixels, + get_piano_roll_pixels: get_piano_roll_pixels, + handle_piano_roll_window_click: handle_piano_roll_window_click, + has_sram: has_sram, + get_sram: get_sram, + set_sram: set_sram, +}; + +function rpc(task, args, reply_channel) { + if (rpc_functions.hasOwnProperty(task)) { + const result = rpc_functions[task].apply(this, args); + reply_channel.postMessage({ result: result }); + } +} + +function handle_message(e) { + idle_accumulator += performance.now() - idle_start; + if (e.data.type == 'rpc') { + rpc(e.data.func, e.data.args, e.ports[0]); + } + if (e.data.type == 'requestFrame') { + // Measure the idle time between each frame, for profiling purposes + collect_profiling('idle', idle_accumulator); + idle_accumulator = 0; + + // Run one step of the emulator + set_p1_input(e.data.p1); + set_p2_input(e.data.p2); + run_one_frame(); + + let outputPanels = []; + let transferrableBuffers = []; + let panel_start_time = performance.now(); + for (let panel of e.data.panels) { + if (panel.id == 'screen') { + let image_buffer = get_screen_pixels(panel.dest_buffer); + outputPanels.push({ + id: 'screen', + target_element: panel.target_element, + image_buffer: image_buffer, + width: 256, + height: 240, + }); + transferrableBuffers.push(image_buffer); + } + } + // Only profile a render if we actually drew something + if (e.data.panels.length > 0) { + collect_profiling( + 'render_all_panels', + performance.now() - panel_start_time + ); + } + // grab memory values + const dataSize = e.data.mem_peek.reduce( + (acc, val, idx) => acc + (idx % 2 ? val : 0), + 0 + ); + const addresses = new Uint16Array(dataSize); + let offset = 0; + for (let i = 0; i < e.data.mem_peek.length; i += 2) { + const startAddr = e.data.mem_peek[i]; + const numBytes = e.data.mem_peek[i + 1]; + for (let j = 0; j < numBytes; j++) { + addresses[offset++] = startAddr + j; + } + } + const byteValues = get_ram(addresses); + + // TODO: this isn't an ArrayBuffer. It probably should be? + let audio_buffer = consume_audio_samples(); + postMessage( + { + type: 'deliverFrame', + panels: outputPanels, + audio_buffer: audio_buffer, + mem_values: byteValues, + }, + transferrableBuffers + ); + } + idle_start = performance.now(); +} + +worker_init = function () { + wasm_init(); + // We are ready to go! Tell the main thread it can kick off execution + initialized = true; + postMessage({ type: 'init' }); + self.onmessage = handle_message; +}; + +wasm_bindgen('./rustico_wasm_bg.wasm').then(worker_init); diff --git a/public/emu/index.html b/public/emu/index.html new file mode 100644 index 00000000..b0b2fa92 --- /dev/null +++ b/public/emu/index.html @@ -0,0 +1,339 @@ + + + + NesTrisChamps Emulator + + + + + + + + + + + +
+ === Profiling Results ===
+
would go here
+
+ +
+
+
+

+ NesTrisChamps includes NO copyrighted material. +

+

+ To play NES Classic Tetris in the emulator provided by + NesTrisChamps, you MUST provide the Classic Tetris + rom yourself. +

+

Click the button below to select the rom and start.

+

+ Note 1: the rom will NOT be uploaded to + NesTrisChamps. It will stay in your browser's local storage. +

+

+ Note 2: the rom will be patched into + Tetris Gym v6. +

+

The rom details are as follow:

+
    +
  • Name: Tetris (U) [!].nes
  • +
  • CRC32: 6d72c53a
  • +
  • + MD5: ec58574d96bee8c8927884ae6e7a2508 +
  • +
+ +

 

+ +

+ +

+ +

+
+
+
+
+
+ Rustico +
+
+ An awesome NES emulator written in Rust, and compiled into high + performance Web Assembly
+ Created by + Zeta - + support on Patreon +
+
+ Tetris Gym v6 +
+
+ The de-facto standard Classic Tetris Practice mod
+ Created by + KirJava +
+
+ RomPatcher.js +
+
+ An awesome rom patcher for bps/ips and more
+ Created by + Marc Robledo + - + donate +
+
+ Zohassadar +
+
+ Great NES hacker who encouraged me to implement direct Tetris memory + access to feed data into NesTrisChamps +
+
+ Alexey Pajitnov +
+
Legendary creator of Tetris
+
+
+
+
+ +
+
+
+
+
+

P1 - Standard

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
A + +
B + +
Select + +
Start + +
Up + +
Down + +
Left + +
Right + +
+
+
+

P2 - Standard

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
A + +
B + +
Select + +
Start + +
Up + +
Down + +
Left + +
Right + +
+
+
+
+
+ + diff --git a/public/emu/input.js b/public/emu/input.js new file mode 100644 index 00000000..14ca1c1f --- /dev/null +++ b/public/emu/input.js @@ -0,0 +1,293 @@ +// Note: The following variable is global, and represents our live button state for the emulator: +// var keys = [0,0]; + +var keys = [0, 0, 0]; +var touch_keys = [0, 0, 0]; +var remap_key = false; +var remap_index = 0; +var remap_slot = 1; + +var controller_keymaps = []; + +controller_keymaps[1] = [ + 'x', + 'z', + 'Shift', + 'Enter', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', +]; + +controller_keymaps[2] = ['-', '-', '-', '-', '-', '-', '-', '-']; + +window.addEventListener('keydown', function (event) { + if (remap_key) { + if (event.key != 'Escape') { + controller_keymaps[remap_slot][remap_index] = event.key; + } else { + controller_keymaps[remap_slot][remap_index] = '-'; + } + remap_key = false; + displayButtonMappings(); + saveInputConfig(); + return; + } + for (var c = 1; c <= 2; c++) { + for (var i = 0; i < 8; i++) { + if (event.key == controller_keymaps[c][i]) { + keys[c] = keys[c] | (0x1 << i); + } + } + } + if (event.key == 'p') { + var debug_box = document.querySelector('#debug-box'); + debug_box.classList.toggle('active'); + } +}); + +window.addEventListener('keyup', function (event) { + for (var c = 1; c <= 2; c++) { + for (var i = 0; i < 8; i++) { + if (event.key == controller_keymaps[c][i]) { + keys[c] = keys[c] & ~(0x1 << i); + } + } + } +}); + +var controller_padmaps = []; +controller_padmaps[1] = ['-', '-', '-', '-', '-', '-', '-', '-']; +controller_padmaps[2] = ['-', '-', '-', '-', '-', '-', '-', '-']; + +var gamepads = []; + +var idle_interval = setInterval(updateGamepads, 500); + +window.addEventListener('gamepadconnected', function (e) { + var gp = navigator.getGamepads()[e.gamepad.index]; + console.log( + 'Recognized new gamepad! Index: ', + e.gamepad.index, + ' Buttons: ', + gp.buttons.length, + ' Axis: ', + gp.axes.length + ); + gamepads[e.gamepad.index] = gamepadState(gp); +}); + +function gamepadState(gamepad) { + var state = { buttons: [], axes: [] }; + for (var b = 0; b < gamepad.buttons.length; b++) { + state.buttons[b] = gamepad.buttons[b].pressed; + } + for (var a = 0; a < gamepad.axes.length; a++) { + state.axes[a] = gamepad.axes[a]; + } + return state; +} + +function updateGamepads() { + for (var i = 0; i < gamepads.length; i++) { + var old_state = gamepads[i]; + if (old_state) { + gp = navigator.getGamepads()[i]; + if (gp) { + var new_state = gamepadState(gp); + for (var b = 0; b < old_state.buttons.length; b++) { + if (old_state.buttons[b] == false && new_state.buttons[b] == true) { + gamepadDown('PAD(' + i + '): BUTTON(' + b + ')'); + } + if (old_state.buttons[b] == true && new_state.buttons[b] == false) { + gamepadUp('PAD(' + i + '): BUTTON(' + b + ')'); + } + } + for (var a = 0; a < old_state.axes.length; a++) { + if (old_state.axes[a] < 0.5 && new_state.axes[a] >= 0.5) { + gamepadDown('PAD(' + i + '): AXIS(' + a + ')+'); + } + if (old_state.axes[a] > -0.5 && new_state.axes[a] <= -0.5) { + gamepadDown('PAD(' + i + '): AXIS(' + a + ')-'); + } + + if (old_state.axes[a] >= 0.5 && new_state.axes[a] < 0.5) { + gamepadUp('PAD(' + i + '): AXIS(' + a + ')+'); + } + if (old_state.axes[a] <= -0.5 && new_state.axes[a] > -0.5) { + gamepadUp('PAD(' + i + '): AXIS(' + a + ')-'); + } + } + gamepads[i] = new_state; + } + } + } +} + +function gamepadDown(button_name) { + if (remap_key) { + controller_padmaps[remap_slot][remap_index] = button_name; + remap_key = false; + displayButtonMappings(); + saveInputConfig(); + return; + } + for (var c = 1; c <= 2; c++) { + for (var i = 0; i < 8; i++) { + if (button_name == controller_padmaps[c][i]) { + keys[c] = keys[c] | (0x1 << i); + } + } + } +} + +function gamepadUp(button_name) { + if (remap_key) { + controller_padmaps[remap_slot][remap_index] = button_name; + remap_key = false; + displayButtonMappings(); + return; + } + for (var c = 1; c <= 2; c++) { + for (var i = 0; i < 8; i++) { + if (button_name == controller_padmaps[c][i]) { + keys[c] = keys[c] & ~(0x1 << i); + } + } + } +} + +function displayButtonMappings() { + var buttons = document.querySelectorAll('#configure_input button'); + buttons.forEach(function (button) { + var key_index = button.getAttribute('data-key'); + var key_slot = button.getAttribute('data-slot'); + button.innerHTML = + controller_keymaps[key_slot][key_index] + + ' / ' + + controller_padmaps[key_slot][key_index]; + button.classList.remove('remapping'); + }); +} + +function remapButton() { + displayButtonMappings(); + this.classList.add('remapping'); + this.innerHTML = '...'; + remap_key = true; + remap_index = this.getAttribute('data-key'); + remap_slot = this.getAttribute('data-slot'); + this.blur(); +} + +function initializeButtonMappings() { + displayButtonMappings(); + var buttons = document.querySelectorAll('#configure_input button'); + buttons.forEach(function (button) { + button.addEventListener('click', remapButton); + }); +} + +function saveInputConfig() { + try { + window.localStorage.setItem( + 'keyboard_1', + JSON.stringify(controller_keymaps[1]) + ); + window.localStorage.setItem( + 'keyboard_2', + JSON.stringify(controller_keymaps[2]) + ); + window.localStorage.setItem( + 'gamepad_1', + JSON.stringify(controller_padmaps[1]) + ); + window.localStorage.setItem( + 'gamepad_2', + JSON.stringify(controller_padmaps[2]) + ); + console.log('Input Config Saved!'); + } catch (e) { + console.log( + 'Local Storage is probably unavailable! Input configuration will not persist.' + ); + } +} + +function loadInputConfig() { + try { + var keyboard_1 = window.localStorage.getItem('keyboard_1'); + if (keyboard_1) { + controller_keymaps[1] = JSON.parse(keyboard_1); + } + var keyboard_2 = window.localStorage.getItem('keyboard_2'); + if (keyboard_2) { + controller_keymaps[2] = JSON.parse(keyboard_2); + } + var gamepad_1 = window.localStorage.getItem('gamepad_1'); + if (gamepad_1) { + controller_padmaps[1] = JSON.parse(gamepad_1); + } + var gamepad_2 = window.localStorage.getItem('gamepad_2'); + if (gamepad_2) { + controller_padmaps[2] = JSON.parse(gamepad_2); + } + console.log('Input Config Loaded!'); + displayButtonMappings(); + } catch (e) { + console.log( + 'Local Storage is probably unavailable! Input configuration will not persist.' + ); + } +} + +KEY_A = 1; +KEY_B = 2; +KEY_SELECT = 4; +KEY_START = 8; +KEY_UP = 16; +KEY_DOWN = 32; +KEY_LEFT = 64; +KEY_RIGHT = 128; + +BUTTON_MAPPING = { + button_a: KEY_A, + button_b: KEY_B, + button_ab: KEY_A | KEY_B, + button_start: KEY_START, + button_select: KEY_SELECT, +}; + +DIRECTION_MAPPING = { + up: KEY_UP, + down: KEY_DOWN, + left: KEY_LEFT, + right: KEY_RIGHT, +}; + +function updateTouchKeys() { + p1_keys = 0; + // Iterate over the button and d-pad states and collect the key status in a byte + // Note: only implemented for P1 at the moment + for (let touch_identifier in active_touches) { + active_touch = active_touches[touch_identifier]; + if (BUTTON_MAPPING.hasOwnProperty(active_touch.button)) { + p1_keys = p1_keys | BUTTON_MAPPING[active_touch.button]; + } + if (active_touch.dpad != null) { + for (let direction of active_touch.directions) { + if (DIRECTION_MAPPING.hasOwnProperty(direction)) { + p1_keys = p1_keys | DIRECTION_MAPPING[direction]; + } + } + } + } + // Because we allow multiple touch points to activate the same D-pad input, we might accidentally produce a directional combination + // that should be disallowed on real hardware. Let's sanity check this and make sure we disallow U+D and L+R + p1_up_left = p1_keys & (KEY_UP | KEY_LEFT); + p1_down_right = p1_up_left << 1; + p1_down_right_mask = p1_down_right ^ 0xff; + + touch_keys[1] = p1_keys & p1_down_right_mask; +} diff --git a/public/emu/main.js b/public/emu/main.js new file mode 100644 index 00000000..9891a12b --- /dev/null +++ b/public/emu/main.js @@ -0,0 +1,762 @@ +import { MarcFile, parseBPSFile } from '/emu/bps.js'; +import { address_maps, getDataAddresses } from '/emu/addresses.js'; +import Connection from '/js/connection.js'; +import BinaryFrame from '/js/BinaryFrame.js'; +import EDGameTracker from '/ocr/EDGameTracker.js'; + +// ========== Global Application State ========== + +let g_pending_frames = 0; +let g_frames_since_last_fps_count = 0; +let g_rendered_frames = []; + +let g_last_frame_sample_count = 44100 / 60; // Close-ish enough +let g_audio_samples_buffered = 0; +let g_new_frame_sample_threshold = 4096; // under which we request a new frame +let g_audio_overrun_sample_threshold = 8192; // over which we *drop* samples + +let g_game_checksum = -1; + +let g_screen_buffers = []; +let g_next_free_buffer_index = 0; +let g_last_rendered_buffer_index = 0; +let g_total_buffers = 16; + +let g_frameskip = 0; +let g_frame_delay = 0; + +let g_audio_confirmed_working = false; +let g_profiling_results = {}; + +let g_trouble_detector = { + successful_samples: 0, + failed_samples: 0, + frames_requested: 0, + trouble_count: 0, + got_better_count: 0, +}; + +let g_increase_frameskip_threshold = 0.01; // percent of missed samples +let g_decrease_frameskip_headroom = 1.5; // percent of the time taken to render one frame + +let g_gymFile = null; +let g_gym_addresses = getDataAddresses(address_maps.gym6); + +const g_connection = new Connection(); +const g_edGameTracker = new EDGameTracker(); + +let last_frame = { field: [] }; + +g_connection.onMessage = () => {}; // ignore everything for now + +g_edGameTracker.onFrame = data => { + if (!data) return; + + // 6. transmit frame to NTC server if necessary + check_equal: do { + for (let key in data) { + if (key[0] === '_') continue; + if (key === 'ctime') continue; + if (key === 'field') { + if (!data.field.every((v, i) => last_frame.field[i] === v)) { + break check_equal; + } + } else if (data[key] != last_frame[key]) { + break check_equal; + } + } + + // all fields equal, do a sanity check on time + if (data.ctime - last_frame.ctime >= 250) break; // max 1 in 15 frames (4fps) + + // no need to send frame + return; + } while (false); + + last_frame = data; + g_connection.send(BinaryFrame.encode(data)); +}; + +// ========== Init which does not depend on DOM ======== + +for (let i = 0; i < g_total_buffers; i++) { + // Allocate a good number of screen buffers + g_screen_buffers[i] = new ArrayBuffer(256 * 240 * 4); +} + +// ========== Worker Setup and Utility ========== + +let worker; + +// the base64 functions below cannot handle very large payloads, but for nes roms and sram, they will do just fine +function bytesToBase64(bytes) { + return btoa(String.fromCharCode(...bytes)); +} + +function base64ToBytes(str) { + return Uint8Array.from(atob(str), c => c.charCodeAt(0)); +} + +function rpc(task, args) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = ({ data }) => { + if (data.error) { + reject(data.error); + } else { + resolve(data.result); + } + }; + worker.postMessage({ type: 'rpc', func: task, args: args }, [ + channel.port2, + ]); + }); +} + +function startWorker() { + worker = new Worker('/emu/emu_worker.js'); + + worker.onmessage = function (e) { + if (e.data.type == 'init') { + onready(); + return; + } + + if (e.data.type == 'deliverFrame') { + if (e.data.panels.length > 0) { + g_rendered_frames.push(e.data.panels); + for (let panel of e.data.panels) { + if (panel.id == 'screen') { + g_screen_buffers[g_last_rendered_buffer_index] = panel.image_buffer; + } + } + g_last_rendered_buffer_index += 1; + if (g_last_rendered_buffer_index >= g_total_buffers) { + g_last_rendered_buffer_index = 0; + } + g_frames_since_last_fps_count += 1; + } + g_pending_frames -= 1; + if (g_audio_samples_buffered < g_audio_overrun_sample_threshold) { + g_nes_audio_node.port.postMessage({ + type: 'samples', + samples: e.data.audio_buffer, + }); + g_audio_samples_buffered += e.data.audio_buffer.length; + g_last_frame_sample_count = e.data.audio_buffer.length; + } else { + // Audio overrun, we're running too fast! Drop these samples on the floor and bail. + // (This can happen in fastforward mode.) + } + if (g_rendered_frames.length > 3) { + // Frame rendering running behing, dropping one frame + g_rendered_frames.shift(); // and throw it away + } + + g_edGameTracker.setData(e.data.mem_values); + } + + if (e.data.type == 'reportPerformance') { + g_profiling_results[e.data.event] = e.data.average_milliseconds; + } + }; +} + +function render_profiling_results() { + let results = ''; + for (let event_name in g_profiling_results) { + let time = g_profiling_results[event_name].toFixed(2); + results += `${event_name}: ${time}\n`; + } + var results_box = document.querySelector('#profiling-results'); + if (results_box != null) { + results_box.innerHTML = results; + } +} + +function automatic_frameskip() { + // first off, do we have enough profiling data collected? + if (g_trouble_detector.frames_requested >= 60) { + let audio_fail_percent = + g_trouble_detector.failed_samples / g_trouble_detector.successful_samples; + if (g_frameskip < 2) { + // if our audio context is running behind, let's try + // rendering fewer frames to compensate + if (audio_fail_percent > g_increase_frameskip_threshold) { + g_trouble_detector.trouble_count += 1; + g_trouble_detector.got_better_count = 0; + console.log('Audio failure percentage: ', audio_fail_percent); + console.log( + 'Trouble count incremented to: ', + g_trouble_detector.trouble_count + ); + if (g_trouble_detector.trouble_count > 3) { + // that's quite enough of that + g_frameskip += 1; + g_trouble_detector.trouble_count = 0; + console.log('Frameskip increased to: ', g_frameskip); + console.log('Trouble reset'); + } + } else { + // Slowly recover from brief trouble spikes + // without taking action + if (g_trouble_detector.trouble_count > 0) { + g_trouble_detector.trouble_count -= 1; + console.log( + 'Trouble count relaxed to: ', + g_trouble_detector.trouble_count + ); + } + } + } + if (g_frameskip > 0) { + // Perform a bunch of sanity checks to see if it looks safe to + // decrease frameskip. + if (audio_fail_percent < g_increase_frameskip_threshold) { + // how long would it take to render one frame right now? + let frame_render_cost = g_profiling_results.render_all_panels; + let cost_with_headroom = + frame_render_cost * g_decrease_frameskip_headroom; + // Would a full render reliably fit in our idle time? + if (cost_with_headroom < g_profiling_results.idle) { + console.log('Frame render costs: ', frame_render_cost); + console.log('With headroom: ', cost_with_headroom); + console.log('Idle time currently: ', g_profiling_results.idle); + g_trouble_detector.got_better_count += 1; + console.log( + 'Recovery count increased to: ', + g_trouble_detector.got_better_count + ); + } + if (cost_with_headroom > g_profiling_results.idle) { + if (g_trouble_detector.got_better_count > 0) { + g_trouble_detector.got_better_count -= 1; + console.log( + 'Recovery count decreased to: ', + g_trouble_detector.got_better_count + ); + } + } + if (g_trouble_detector.got_better_count >= 10) { + g_frameskip -= 1; + console.log('Performance recovered! Lowering frameskip by 1 to: '); + g_trouble_detector.got_better_count = 0; + } + } + } + + // now reset the counters for the next run + g_trouble_detector.frames_requested = 0; + g_trouble_detector.failed_samples = 0; + g_trouble_detector.successful_samples = 0; + } +} + +// ========== Audio Setup ========== + +let g_audio_context = null; +let g_nes_audio_node = null; + +async function init_audio_context() { + g_audio_context = new AudioContext({ + latencyHint: 'interactive', + sampleRate: 44100, + }); + await g_audio_context.audioWorklet.addModule('/emu/audio_processor.js'); + g_nes_audio_node = new AudioWorkletNode( + g_audio_context, + 'nes-audio-processor' + ); + g_nes_audio_node.connect(g_audio_context.destination); + g_nes_audio_node.port.onmessage = handle_audio_message; +} + +function handle_audio_message(e) { + if (e.data.type == 'samplesPlayed') { + g_audio_samples_buffered -= e.data.count; + g_trouble_detector.successful_samples += e.data.count; + if ( + !g_audio_confirmed_working && + g_trouble_detector.successful_samples > 44100 + ) { + let audio_context_banner = document.querySelector( + '#audio-context-warning' + ); + if (audio_context_banner != null) { + audio_context_banner.classList.remove('active'); + } + g_audio_confirmed_working = true; + } + } + if (e.data.type == 'audioUnderrun') { + g_trouble_detector.failed_samples += e.data.count; + } +} + +// ========== Main ========== + +async function onready() { + // Initialize audio context, this will also begin audio playback + await init_audio_context(); + + // Initialize everything else + init_ui_events(); + initializeButtonMappings(); + + // Kick off the events that will drive emulation + requestAnimationFrame(renderLoop); + // run the scheduler as often as we can. It will frequently decide not to schedule things, this is fine. + //window.setInterval(schedule_frames_at_top_speed, 1); + window.setTimeout(sync_to_audio, 1); + window.setInterval(compute_fps, 1000); + window.setInterval(render_profiling_results, 1000); + window.setInterval(automatic_frameskip, 1000); + window.setInterval(save_sram_periodically, 10000); + + // load gym + load_cartridge(g_gymFile._u8array); +} + +function init_ui_events() { + // Setup UI events + // document.getElementById('file-loader').addEventListener('change', load_cartridge_by_file, false); + + var buttons = document.querySelectorAll('#main_menu button'); + buttons.forEach(function (button) { + button.addEventListener('click', clickTab); + }); + + window.addEventListener('click', function () { + // Needed to play audio in certain browsers, notably Chrome, which restricts playback until user action. + g_audio_context.resume(); + }); + + document + .querySelector('#playfield') + .addEventListener('dblclick', enterFullscreen); + window.addEventListener('resize', handleFullscreenSwitch); + + register_touch_button('#button_a'); + register_touch_button('#button_b'); + register_touch_button('#button_ab'); + register_touch_button('#button_select'); + register_touch_button('#button_start'); + register_d_pad('#d_pad'); + initialize_touch('#playfield'); + + handleFullscreenSwitch(); +} + +// ========== Cartridge Management ========== + +async function load_cartridge(cart_data /*uint8array*/) { + console.log('Attempting to load cart with length: ', cart_data.length); + await rpc('load_cartridge', [cart_data]); + console.log('Cart data loaded?'); + + g_game_checksum = crc32(cart_data); + load_sram(); + let power_light = document.querySelector('#power_light #led'); + power_light.classList.add('powered'); +} + +// ========== Emulator Runtime ========== + +function schedule_frames_at_top_speed() { + if (g_pending_frames < 10) { + requestFrame(); + } + window.setTimeout(schedule_frames_at_top_speed, 1); +} + +function sync_to_audio() { + // On mobile browsers, sometimes window.setTimeout isn't called often enough to reliably + // queue up single frames; try to catch up by up to 4 of them at once. + for (let i = 0; i < 4; i++) { + // Never, for any reason, request more than 10 frames at a time. This prevents + // the message queue from getting flooded if the emulator can't keep up. + if (g_pending_frames < 10) { + const actual_samples = g_audio_samples_buffered; + const pending_samples = g_pending_frames * g_last_frame_sample_count; + if (actual_samples + pending_samples < g_new_frame_sample_threshold) { + requestFrame(); + } + } + } + window.setTimeout(sync_to_audio, 1); +} + +function requestFrame() { + updateTouchKeys(); + g_trouble_detector.frames_requested += 1; + if (g_frame_delay > 0) { + // frameskip: advance the emulation, but do not populate or render + // any panels this time around + worker.postMessage({ + type: 'requestFrame', + p1: keys[1] | touch_keys[1], + p2: keys[2] | touch_keys[2], + mem_peek: g_gym_addresses, + panels: [], + }); + g_frame_delay -= 1; + g_pending_frames += 1; + return; + } + + worker.postMessage( + { + type: 'requestFrame', + p1: keys[1] | touch_keys[1], + p2: keys[2] | touch_keys[2], + mem_peek: g_gym_addresses, + panels: [ + { + id: 'screen', + target_element: '#pixels', + dest_buffer: g_screen_buffers[g_next_free_buffer_index], + }, + ], + }, + [g_screen_buffers[g_next_free_buffer_index]] + ); + + g_pending_frames += 1; + g_next_free_buffer_index += 1; + if (g_next_free_buffer_index >= g_total_buffers) { + g_next_free_buffer_index = 0; + } + g_frame_delay = g_frameskip; +} + +function renderLoop() { + if (g_rendered_frames.length > 0) { + for (let panel of g_rendered_frames.shift()) { + const typed_pixels = new Uint8ClampedArray(panel.image_buffer); + // TODO: don't hard-code the panel size here + const rendered_frame = new ImageData( + typed_pixels, + panel.width, + panel.height + ); + const canvas = document.querySelector(panel.target_element); + const ctx = canvas.getContext('2d', { alpha: false }); + ctx.putImageData(rendered_frame, 0, 0); + ctx.imageSmoothingEnabled = false; + } + } + + requestAnimationFrame(renderLoop); +} + +// ========== SRAM Management ========== + +// CRC32 checksum generating functions, yanked from this handy stackoverflow post and modified to work with arrays: +// https://stackoverflow.com/questions/18638900/javascript-crc32 +// Used to identify .nes files semi-uniquely, for the purpose of saving SRAM +var makeCRCTable = function () { + var c; + var crcTable = []; + for (var n = 0; n < 256; n++) { + c = n; + for (var k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + crcTable[n] = c; + } + return crcTable; +}; + +var crc32 = function (byte_array) { + var crcTable = window.crcTable || (window.crcTable = makeCRCTable()); + var crc = 0 ^ -1; + + for (var i = 0; i < byte_array.length; i++) { + crc = (crc >>> 8) ^ crcTable[(crc ^ byte_array[i]) & 0xff]; + } + + return (crc ^ -1) >>> 0; +}; + +async function load_sram() { + if (await rpc('has_sram')) { + try { + var sram_str = window.localStorage.getItem(g_game_checksum); + if (sram_str) { + await rpc('set_sram', [base64ToBytes(sram_str)]); + console.log('SRAM Loaded!', g_game_checksum); + } + } catch (e) { + console.log( + 'Local Storage is probably unavailable! SRAM saving and loading will not work.' + ); + } + } +} + +async function save_sram() { + if (await rpc('has_sram')) { + try { + const sram_uint8 = await rpc('get_sram', []); + // Make it a normal array + window.localStorage.setItem(g_game_checksum, bytesToBase64(sram_uint8)); + console.log('SRAM Saved!', g_game_checksum); + } catch (e) { + console.log( + 'Local Storage is probably unavailable! SRAM saving and loading will not work.' + ); + } + } +} + +function save_sram_periodically() { + save_sram(); +} + +// ========== User Interface ========== + +// This runs *around* once per second, ish. It's fine. +function compute_fps() { + let counter_element = document.querySelector('#fps-counter'); + if (counter_element != null) { + counter_element.innerText = 'FPS: ' + g_frames_since_last_fps_count; + } + g_frames_since_last_fps_count = 0; +} + +function clearTabs() { + const buttons = document.querySelectorAll('#main_menu button'); + buttons.forEach(function (button) { + button.classList.remove('active'); + }); + + const tabs = document.querySelectorAll('div.tab_content'); + tabs.forEach(function (tab) { + tab.classList.remove('active'); + }); +} + +function switchToTab(tab_name) { + clearTabs(); + + const tab_elements = document.getElementsByName(tab_name); + tab_elements[0]?.classList.add('active'); + + const content_element = document.getElementById(tab_name); + content_element?.classList.add('active'); +} + +function clickTab() { + const tabName = this.getAttribute('name'); + + if (tabName == 'fullscreen') { + switchToTab('playfield'); + enterFullscreen(); + } else { + switchToTab(tabName); + } +} + +function enterFullscreen() { + var viewport = document.querySelector('#playfield'); + ( + viewport.requestFullscreen || + viewport.mozRequestFullScreen || + viewport.webkitRequestFullscreen + ).call(viewport); +} + +function isFullScreen() { + return !!( + document.fullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + document.msFullscreenElement + ); +} + +function handleFullscreenSwitch() { + const viewport = document.querySelector('#playfield'); + const canvas_container = viewport.querySelector('div.canvas_container'); + + if (isFullScreen()) { + console.log('Entering fullscreen...'); + // Entering fullscreen + viewport.classList.add('fullscreen'); + viewport.classList.remove('horizontal', 'vertical'); + if (is_touch_detected) { + viewport.classList.add('touchscreen'); + } else { + viewport.classList.remove('touchscreen'); + } + } else { + // Exiting fullscreen + console.log('Exiting fullscreen...'); + viewport.classList.remove( + 'fullscreen', + 'touchscreen', + 'horizontal', + 'vertical' + ); + canvas_container.style.width = ''; + canvas_container.style.height = ''; + } + + const viewport_width = viewport.clientWidth; + const viewport_height = viewport.clientHeight; + const viewport_ratio = viewport_width / viewport_height; + const target_ratio = 256 / 240; + + let target_height, target_width; + + if (viewport_ratio > target_ratio) { + target_height = viewport_height; + target_width = target_height * target_ratio; + viewport.classList.add('horizontal'); + } else { + target_width = viewport_width; + target_height = target_width / target_ratio; + viewport.classList.add('vertical'); + } + + canvas_container.style.width = target_width + 'px'; + canvas_container.style.height = target_height + 'px'; +} + +function hide_banners() { + banner_elements = document.querySelectorAll('.banner'); + banner_elements.forEach(function (banner) { + banner.classList.remove('active'); + }); +} + +function display_banner(cartridge_name) { + hide_banners(); + banner_elements = document.getElementsByName(cartridge_name); + if (banner_elements.length == 1) { + banner_elements[0].classList.add('active'); + } +} + +// ========== NTC rom management ========== + +function showOpenFilePickerPolyfill(options) { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = options.multiple; + if (options.types) { + input.accept = options.types + .map(type => type.accept) + .flatMap(inst => Object.keys(inst).flatMap(key => inst[key])) + .join(','); + } + + // See https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only + input.style.position = 'fixed'; + input.style.top = '-100000px'; + input.style.left = '-100000px'; + document.body.appendChild(input); + + input.addEventListener('change', () => { + resolve( + [...input.files].map(file => { + return { + getFile: async () => + new Promise(resolve => { + resolve(file); + }), + }; + }) + ); + }); + + input.click(); + }); +} + +if (typeof window.showOpenFilePicker !== 'function') { + window.showOpenFilePicker = showOpenFilePickerPolyfill; +} + +const g_first_time = document.querySelector('#load_rom'); +const patch_url = '/emu/TetrisGYM-6.0.0.bps'; + +let emulator; + +function initFirstTime() { + // Make the user to perform the only action that matters at this point: selecting the tetris rom + // Hide controls and banners + document.querySelector('#main_menu').style.display = 'none'; + document.querySelector('#fps-counter').style.display = 'none'; + document.querySelector('.banner.active').classList.remove('active'); + switchToTab('load_rom'); + + const button = g_first_time.querySelector('button'); + + button.addEventListener('click', async () => { + g_first_time.querySelector('.error').textContent = ''; + + const [fileHandle] = await showOpenFilePicker({ + multiple: false, + }); + const file = await fileHandle.getFile(); + const content = new Uint8Array(await file.arrayBuffer()); + + patchVanillaRomAndStart(content); + }); +} + +async function patchVanillaRomAndStart(romContent) { + // fetch patch - store patch in local storage? + const response = await fetch(patch_url); + const patchContent = await response.arrayBuffer(); + + const romFile = new MarcFile(romContent); + const patchFile = new MarcFile(patchContent); + + const bps = parseBPSFile(patchFile); + + try { + g_gymFile = bps.apply(romFile, true); + } catch (err) { + const error = g_first_time.querySelector('.error'); + + if (err.message === 'error_crc_input') { + error.textContent = 'Checksum does not match, invalid rom provided.'; + } else { + error.textContent = `Unexpected patch error: ${err.message}`; + } + + return; + } + + // if we reach here, patching is OK, attempt to save the rom, but ignore if unable to + try { + localStorage.setItem('tetris.nes', bytesToBase64(romContent)); + } catch (err) { + console.warn( + `Unable to save tetris rom to local storage. You will need to provide the rom again if you refresh.`, + err + ); + } + + g_first_time.remove(); + + document.querySelector('#main_menu').style.display = null; + document.querySelector('#fps-counter').style.display = null; + switchToTab('playfield'); + + startWorker(); +} + +function run() { + const encoded64VanillaRomContent = localStorage.getItem('tetris.nes'); + if (!encoded64VanillaRomContent) { + initFirstTime(); + } else { + patchVanillaRomAndStart(base64ToBytes(encoded64VanillaRomContent)); + } +} + +run(); diff --git a/public/emu/rustico_wasm.d.ts b/public/emu/rustico_wasm.d.ts new file mode 100644 index 00000000..de02a5e8 --- /dev/null +++ b/public/emu/rustico_wasm.d.ts @@ -0,0 +1,124 @@ +declare namespace wasm_bindgen { + /* tslint:disable */ + /* eslint-disable */ + /** + */ + export function wasm_init(): void; + /** + * @param {Uint8Array} cart_data + */ + export function load_rom(cart_data: Uint8Array): void; + /** + */ + export function run_until_vblank(): void; + /** + */ + export function update_windows(): void; + /** + * @param {Uint8Array} pixels + */ + export function draw_screen_pixels(pixels: Uint8Array): void; + /** + * @param {Uint8Array} dest + */ + export function draw_apu_window(dest: Uint8Array): void; + /** + * @param {Uint8Array} dest + */ + export function draw_piano_roll_window(dest: Uint8Array): void; + /** + * @param {number} keystate + */ + export function set_p1_input(keystate: number): void; + /** + * @param {number} keystate + */ + export function set_p2_input(keystate: number): void; + /** + * @param {number} sample_rate + */ + export function set_audio_samplerate(sample_rate: number): void; + /** + * @param {number} buffer_size + */ + export function set_audio_buffersize(buffer_size: number): void; + /** + * @returns {boolean} + */ + export function audio_buffer_full(): boolean; + /** + * @returns {Int16Array} + */ + export function get_audio_buffer(): Int16Array; + /** + * @returns {Int16Array} + */ + export function consume_audio_samples(): Int16Array; + /** + * @param {Uint16Array} addresses + * @returns {Uint8Array} + */ + export function get_ram(addresses: Uint16Array): Uint8Array; + /** + * @returns {Uint8Array} + */ + export function get_sram(): Uint8Array; + /** + * @param {Uint8Array} sram + */ + export function set_sram(sram: Uint8Array): void; + /** + * @returns {boolean} + */ + export function has_sram(): boolean; + /** + * @param {number} mx + * @param {number} my + */ + export function piano_roll_window_click(mx: number, my: number): void; +} + +declare type InitInput = + | RequestInfo + | URL + | Response + | BufferSource + | WebAssembly.Module; + +declare interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly wasm_init: () => void; + readonly load_rom: (a: number, b: number) => void; + readonly run_until_vblank: () => void; + readonly update_windows: () => void; + readonly draw_screen_pixels: (a: number, b: number, c: number) => void; + readonly draw_apu_window: (a: number, b: number, c: number) => void; + readonly draw_piano_roll_window: (a: number, b: number, c: number) => void; + readonly set_p1_input: (a: number) => void; + readonly set_p2_input: (a: number) => void; + readonly set_audio_samplerate: (a: number) => void; + readonly set_audio_buffersize: (a: number) => void; + readonly audio_buffer_full: () => number; + readonly get_audio_buffer: (a: number) => void; + readonly consume_audio_samples: (a: number) => void; + readonly get_ram: (a: number, b: number, c: number) => void; + readonly get_sram: (a: number) => void; + readonly set_sram: (a: number, b: number) => void; + readonly has_sram: () => number; + readonly piano_roll_window_click: (a: number, b: number) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; +} + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {InitInput | Promise} module_or_path + * + * @returns {Promise} + */ +declare function wasm_bindgen( + module_or_path?: InitInput | Promise +): Promise; diff --git a/public/emu/rustico_wasm.js b/public/emu/rustico_wasm.js new file mode 100644 index 00000000..8feb8afc --- /dev/null +++ b/public/emu/rustico_wasm.js @@ -0,0 +1,384 @@ +let wasm_bindgen; +(function () { + const __exports = {}; + let script_src; + if (typeof document !== 'undefined' && document.currentScript !== null) { + script_src = new URL(document.currentScript.src, location.href).toString(); + } + let wasm = undefined; + + let cachedUint8Memory0 = null; + + function getUint8Memory0() { + if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { + cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8Memory0; + } + + function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8Memory0().subarray(ptr / 1, ptr / 1 + len); + } + + const heap = new Array(128).fill(undefined); + + heap.push(undefined, null, true, false); + + function getObject(idx) { + return heap[idx]; + } + + let heap_next = heap.length; + + function dropObject(idx) { + if (idx < 132) return; + heap[idx] = heap_next; + heap_next = idx; + } + + function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; + } + /** + */ + __exports.wasm_init = function () { + wasm.wasm_init(); + }; + + let WASM_VECTOR_LEN = 0; + + function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8Memory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; + } + /** + * @param {Uint8Array} cart_data + */ + __exports.load_rom = function (cart_data) { + const ptr0 = passArray8ToWasm0(cart_data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.load_rom(ptr0, len0); + }; + + /** + */ + __exports.run_until_vblank = function () { + wasm.run_until_vblank(); + }; + + /** + */ + __exports.update_windows = function () { + wasm.update_windows(); + }; + + function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; + } + /** + * @param {Uint8Array} pixels + */ + __exports.draw_screen_pixels = function (pixels) { + var ptr0 = passArray8ToWasm0(pixels, wasm.__wbindgen_malloc); + var len0 = WASM_VECTOR_LEN; + wasm.draw_screen_pixels(ptr0, len0, addHeapObject(pixels)); + }; + + /** + * @param {Uint8Array} dest + */ + __exports.draw_apu_window = function (dest) { + var ptr0 = passArray8ToWasm0(dest, wasm.__wbindgen_malloc); + var len0 = WASM_VECTOR_LEN; + wasm.draw_apu_window(ptr0, len0, addHeapObject(dest)); + }; + + /** + * @param {Uint8Array} dest + */ + __exports.draw_piano_roll_window = function (dest) { + var ptr0 = passArray8ToWasm0(dest, wasm.__wbindgen_malloc); + var len0 = WASM_VECTOR_LEN; + wasm.draw_piano_roll_window(ptr0, len0, addHeapObject(dest)); + }; + + /** + * @param {number} keystate + */ + __exports.set_p1_input = function (keystate) { + wasm.set_p1_input(keystate); + }; + + /** + * @param {number} keystate + */ + __exports.set_p2_input = function (keystate) { + wasm.set_p2_input(keystate); + }; + + /** + * @param {number} sample_rate + */ + __exports.set_audio_samplerate = function (sample_rate) { + wasm.set_audio_samplerate(sample_rate); + }; + + /** + * @param {number} buffer_size + */ + __exports.set_audio_buffersize = function (buffer_size) { + wasm.set_audio_buffersize(buffer_size); + }; + + /** + * @returns {boolean} + */ + __exports.audio_buffer_full = function () { + const ret = wasm.audio_buffer_full(); + return ret !== 0; + }; + + let cachedInt32Memory0 = null; + + function getInt32Memory0() { + if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { + cachedInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachedInt32Memory0; + } + + let cachedInt16Memory0 = null; + + function getInt16Memory0() { + if (cachedInt16Memory0 === null || cachedInt16Memory0.byteLength === 0) { + cachedInt16Memory0 = new Int16Array(wasm.memory.buffer); + } + return cachedInt16Memory0; + } + + function getArrayI16FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getInt16Memory0().subarray(ptr / 2, ptr / 2 + len); + } + /** + * @returns {Int16Array} + */ + __exports.get_audio_buffer = function () { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.get_audio_buffer(retptr); + var r0 = getInt32Memory0()[retptr / 4 + 0]; + var r1 = getInt32Memory0()[retptr / 4 + 1]; + var v1 = getArrayI16FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 2, 2); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + }; + + /** + * @returns {Int16Array} + */ + __exports.consume_audio_samples = function () { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.consume_audio_samples(retptr); + var r0 = getInt32Memory0()[retptr / 4 + 0]; + var r1 = getInt32Memory0()[retptr / 4 + 1]; + var v1 = getArrayI16FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 2, 2); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + }; + + let cachedUint16Memory0 = null; + + function getUint16Memory0() { + if (cachedUint16Memory0 === null || cachedUint16Memory0.byteLength === 0) { + cachedUint16Memory0 = new Uint16Array(wasm.memory.buffer); + } + return cachedUint16Memory0; + } + + function passArray16ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 2, 2) >>> 0; + getUint16Memory0().set(arg, ptr / 2); + WASM_VECTOR_LEN = arg.length; + return ptr; + } + /** + * @param {Uint16Array} addresses + * @returns {Uint8Array} + */ + __exports.get_ram = function (addresses) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray16ToWasm0(addresses, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.get_ram(retptr, ptr0, len0); + var r0 = getInt32Memory0()[retptr / 4 + 0]; + var r1 = getInt32Memory0()[retptr / 4 + 1]; + var v2 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 1, 1); + return v2; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + }; + + /** + * @returns {Uint8Array} + */ + __exports.get_sram = function () { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.get_sram(retptr); + var r0 = getInt32Memory0()[retptr / 4 + 0]; + var r1 = getInt32Memory0()[retptr / 4 + 1]; + var v1 = getArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 1, 1); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + }; + + /** + * @param {Uint8Array} sram + */ + __exports.set_sram = function (sram) { + const ptr0 = passArray8ToWasm0(sram, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.set_sram(ptr0, len0); + }; + + /** + * @returns {boolean} + */ + __exports.has_sram = function () { + const ret = wasm.has_sram(); + return ret !== 0; + }; + + /** + * @param {number} mx + * @param {number} my + */ + __exports.piano_roll_window_click = function (mx, my) { + wasm.piano_roll_window_click(mx, my); + }; + + async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn( + '`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n', + e + ); + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + } + + function __wbg_get_imports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbindgen_copy_to_typed_array = function (arg0, arg1, arg2) { + new Uint8Array( + getObject(arg2).buffer, + getObject(arg2).byteOffset, + getObject(arg2).byteLength + ).set(getArrayU8FromWasm0(arg0, arg1)); + }; + imports.wbg.__wbindgen_object_drop_ref = function (arg0) { + takeObject(arg0); + }; + + return imports; + } + + function __wbg_init_memory(imports, maybe_memory) {} + + function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + __wbg_init.__wbindgen_wasm_module = module; + cachedInt16Memory0 = null; + cachedInt32Memory0 = null; + cachedUint16Memory0 = null; + cachedUint8Memory0 = null; + + return wasm; + } + + function initSync(module) { + if (wasm !== undefined) return wasm; + + const imports = __wbg_get_imports(); + + __wbg_init_memory(imports); + + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + + const instance = new WebAssembly.Instance(module, imports); + + return __wbg_finalize_init(instance, module); + } + + async function __wbg_init(input) { + if (wasm !== undefined) return wasm; + + if (typeof input === 'undefined' && typeof script_src !== 'undefined') { + input = script_src.replace(/\.js$/, '_bg.wasm'); + } + const imports = __wbg_get_imports(); + + if ( + typeof input === 'string' || + (typeof Request === 'function' && input instanceof Request) || + (typeof URL === 'function' && input instanceof URL) + ) { + input = fetch(input); + } + + __wbg_init_memory(imports); + + const { instance, module } = await __wbg_load(await input, imports); + + return __wbg_finalize_init(instance, module); + } + + wasm_bindgen = Object.assign(__wbg_init, { initSync }, __exports); +})(); diff --git a/public/emu/rustico_wasm_bg.wasm b/public/emu/rustico_wasm_bg.wasm new file mode 100644 index 00000000..4ac230d1 Binary files /dev/null and b/public/emu/rustico_wasm_bg.wasm differ diff --git a/public/emu/rustico_wasm_bg.wasm.d.ts b/public/emu/rustico_wasm_bg.wasm.d.ts new file mode 100644 index 00000000..0d09bcb0 --- /dev/null +++ b/public/emu/rustico_wasm_bg.wasm.d.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export function wasm_init(): void; +export function load_rom(a: number, b: number): void; +export function run_until_vblank(): void; +export function update_windows(): void; +export function draw_screen_pixels(a: number, b: number, c: number): void; +export function draw_apu_window(a: number, b: number, c: number): void; +export function draw_piano_roll_window(a: number, b: number, c: number): void; +export function set_p1_input(a: number): void; +export function set_p2_input(a: number): void; +export function set_audio_samplerate(a: number): void; +export function set_audio_buffersize(a: number): void; +export function audio_buffer_full(): number; +export function get_audio_buffer(a: number): void; +export function consume_audio_samples(a: number): void; +export function get_ram(a: number, b: number, c: number): void; +export function get_sram(a: number): void; +export function set_sram(a: number, b: number): void; +export function has_sram(): number; +export function piano_roll_window_click(a: number, b: number): void; +export function __wbindgen_malloc(a: number, b: number): number; +export function __wbindgen_add_to_stack_pointer(a: number): number; +export function __wbindgen_free(a: number, b: number, c: number): void; diff --git a/public/emu/style.css b/public/emu/style.css new file mode 100644 index 00000000..347904cf --- /dev/null +++ b/public/emu/style.css @@ -0,0 +1,317 @@ +body, +html { + width: 100%; + margin: 0px; + padding: 0px; + font-family: sans-serif; +} + +.code { + font-family: monospace; +} + +html { + background-color: #080808; + height: 100%; +} + +body { + background-color: #030303; + color: #fdf0f1; + box-shadow: 0 0 16px #040404; + width: 1200px; + height: 100%; + margin-left: auto; + margin-right: auto; + position: relative; +} + +#header { + padding-left: 16px; + padding-right: 64px; +} + +#header h1 { + color: #666; + font-size: 25px; + margin: 0px; + padding: 0px; + line-height: 64px; + flex-grow: 0; +} + +#navbar { + height: 64px; + display: flex; + flex-direction: row; + position: relative; + background-color: #040404; + margin-top: 0px; + border-bottom: 2px solid #222; + background-image: url('wavy_grid_gradient.png'); +} + +#main_menu { + display: flex; + flex-direction: row; + margin: 0px; + padding: 0px; + flex-grow: 1; +} + +#fps-counter { + line-height: 64px; + font-size: 0.8em; + text-align: right; + padding-right: 12px; + font-weight: bold; + color: #666; +} + +#main_menu li { + padding-top: 10px; + height: 32px; + display: block; + margin: 6px; + text-align: right; + text-transform: uppercase; + font-weight: bold; +} + +#main_menu li button, +#main_menu li label { + display: block; + padding-top: 10px; + padding-left: 10px; + padding-right: 5px; + padding-bottom: 5px; + font-size: 12px; + font-weight: bold; + border-top: none; + border-left: none; + border-right: 3px solid #181818; + border-bottom: 3px solid #111; + background-color: #222; + color: #888; + text-decoration: none; + text-transform: uppercase; + cursor: pointer; +} + +#main_menu li button:hover, +#main_menu li label:hover { + background-color: #333; +} + +#main_menu li button.active { + background-color: #181818; + border-right: 1px solid #0c0c0c; + border-bottom: 1px solid #080808; + color: #666; + /* Transparent top-left, for offset purposes */ + border-top: 2px solid rgba(0, 0, 0, 0); + border-left: 2px solid rgba(0, 0, 0, 0); +} + +#main_menu li button.active:hover { + background-color: #222; +} + +#main_menu #power_light { + display: flex; + align-items: center; + border: none; + box-shadow: none; +} + +#led { + width: 10px; + height: 10px; + margin-left: 10px; + border: 1px solid #1b1917; + background-color: #353233; +} + +#led.powered { + background-color: #8b0000; + box-shadow: 0px 0px 4px #6a0000; +} + +#content_area { + position: relative; + height: calc(100% - 66px); + overflow: auto; +} + +.tab_content { + display: none; + color: #dddddd; + height: 100%; + overflow: hidden; +} + +.tab_content.active { + display: flex; + justify-content: center; + align-items: center; +} + +#credits { + color: #666; +} + +#credits a, +#load_rom a { + color: #888; +} + +#credits dt { + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +#load_rom button { + padding: 1em; +} + +#load_rom .error { + text-align: center; + color: #c33; +} + +#playfield.active { + display: flex; + height: 100%; + flex-direction: column; + justify-content: center; +} + +#playfield.fullscreen { + width: 100%; + height: 100%; + padding-top: 0px; + background-color: black; +} + +#playfield div.canvas_container { + position: relative; + width: 768px; + height: 720px; + margin-left: auto; + margin-right: auto; + overflow: hidden; +} + +div.canvas_container img.overlay { + display: block; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + -ms-interpolation-mode: nearest-neighbor; + image-rendering: pixelated; +} + +canvas { + width: 100%; + height: 100%; + + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + -ms-interpolation-mode: nearest-neighbor; + image-rendering: pixelated; +} + +.flex_columns { + display: flex; + flex-direction: row; + gap: 5em; +} + +.flex_rows { + display: flex; + flex-direction: column; +} + +#configure_input .flex_columns div { + margin: auto; +} + +#configure_input { + padding-left: 10px; + padding-right: 10px; +} + +#configure_input td { + text-align: right; + padding: 3px; + padding-left: 10px; +} + +#configure_input button { + width: 300px; + border: none; + border-radius: 9px; + padding: 2px; + background-color: #727272; + color: #77221a; + font-weight: bold; + text-transform: uppercase; +} + +.banner { + display: none; +} + +.banner.active { + display: block; + height: 30px; + line-height: 30px; + text-align: center; + background-color: #111; + border-bottom: 2px solid #222; + color: #555; + font-size: 11px; + font-weight: bold; +} + +.banner a { + color: #777; + text-decoration: none; +} + +.banner a:hover { + color: #999; +} + +.banner.active.error { + background-color: #311; + border-bottom: 2px solid #622; + color: #755; +} + +.debug-box { + display: none; +} + +.debug-box.active { + display: block; + text-align: center; + background-color: #441; + border-bottom: 2px solid #552; + color: #885; + font-size: 11px; + font-weight: bold; +} + +.debug-box pre { + text-align: left; + width: 200px; + margin-left: 500px; +} diff --git a/public/emu/touch.js b/public/emu/touch.js new file mode 100644 index 00000000..1cf7986d --- /dev/null +++ b/public/emu/touch.js @@ -0,0 +1,372 @@ +is_touch_detected = false; +touch_button_elements = []; +dpad_elements = []; +active_touches = {}; + +stickiness_radius = 5; // pixels, ish + +dpad_inner_deadzone_percent = 0.25; +dpad_extra_radius_percent = 0.1; +dpad_sticky_degrees = 5; +dpad_cardinal_priority_degrees = 10; + +function register_touch_button(querystring) { + var button_element = document.querySelector(querystring); + if (button_element) { + touch_button_elements.push(button_element); + } else { + console.log('Could not find element ', querystring); + } +} + +function register_d_pad(querystring) { + var dpad_element = document.querySelector(querystring); + if (dpad_element) { + dpad_elements.push(dpad_element); + } else { + console.log('Could not find element ', querystring); + } +} + +// Relative to the viewport +function element_centerpoint(element) { + let rect = element.getBoundingClientRect(); + let cx = rect.left + rect.width / 2; + let cy = rect.top + rect.height / 2; + return [cx, cy]; +} + +function element_radius(element) { + let rect = element.getBoundingClientRect(); + let longest_side = Math.max(rect.width, rect.height); + return longest_side / 2; +} + +function angle_to_element(touch, element) { + let [cx, cy] = element_centerpoint(element); + let tx = touch.clientX; + let ty = touch.clientY; + let dx = tx - cx; + let dy = ty - cy; + let angle_radians = Math.atan2(dy * -1, dx); + let angle_degrees = (angle_radians * 180) / Math.PI; + if (angle_degrees < 0) { + angle_degrees += 360.0; + } + return angle_degrees; +} + +function is_inside_button(touch, element) { + let [cx, cy] = element_centerpoint(element); + let radius = element_radius(element); + let tx = touch.clientX; + let ty = touch.clientY; + let dx = tx - cx; + let dy = ty - cy; + let distance_squared = dx * dx + dy * dy; + let radius_squared = radius * radius; + return distance_squared < radius_squared; +} + +function is_stuck_to_button(touch, element) { + // Very similar to is_inside_element, but with the stickiness radius applied + let [cx, cy] = element_centerpoint(element); + let radius = element_radius(element) + stickiness_radius; + let tx = touch.clientX; + let ty = touch.clientY; + let dx = tx - cx; + let dy = ty - cy; + let distance_squared = dx * dx + dy * dy; + let radius_squared = radius * radius; + return distance_squared < radius_squared; +} + +function is_inside_dpad(touch, element) { + let [cx, cy] = element_centerpoint(element); + let base_radius = element_radius(element) + stickiness_radius; + let outer_radius = base_radius + base_radius * dpad_extra_radius_percent; + let deadzone_radius = base_radius * dpad_inner_deadzone_percent; + let tx = touch.clientX; + let ty = touch.clientY; + let dx = tx - cx; + let dy = ty - cy; + let distance_squared = dx * dx + dy * dy; + let outer_radius_squared = outer_radius * outer_radius; + let deadzone_radius_squared = deadzone_radius * deadzone_radius; + return ( + distance_squared > deadzone_radius_squared && + distance_squared < outer_radius_squared + ); +} + +function is_sticky_dpad(angle) { + // (ordered counter-clockwise, starting with 0 degrees "east") + + // East is split along the X axis + if (angle < 22.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees) { + return false; + } + if (angle > 337.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees) { + return false; + } + + // North East + if ( + angle > 22.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 67.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // North + if ( + angle > 67.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 112.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // North West + if ( + angle > 112.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 157.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // West + if ( + angle > 157.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 202.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // South West + if ( + angle > 202.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 247.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // South + if ( + angle > 247.5 - dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 292.5 + dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + // South East + if ( + angle > 292.5 + dpad_cardinal_priority_degrees + dpad_sticky_degrees && + angle < 337.5 - dpad_cardinal_priority_degrees - dpad_sticky_degrees + ) { + return false; + } + + return true; +} + +function dpad_directions(touch, element, old_directions) { + let has_previous_directions = old_directions.length > 0; + let dpad_angle = angle_to_element(touch, element); + if (has_previous_directions && is_sticky_dpad(dpad_angle)) { + return old_directions; + } + + // East is split along the X axis + if (dpad_angle < 22.5 + dpad_cardinal_priority_degrees) { + return ['right']; + } + if (dpad_angle > 337.5 - dpad_cardinal_priority_degrees) { + return ['right']; + } + + // North East + if ( + dpad_angle > 22.5 + dpad_cardinal_priority_degrees && + dpad_angle < 67.5 - dpad_cardinal_priority_degrees + ) { + return ['up', 'right']; + } + + // North + if ( + dpad_angle > 67.5 - dpad_cardinal_priority_degrees && + dpad_angle < 112.5 + dpad_cardinal_priority_degrees + ) { + return ['up']; + } + + // North West + if ( + dpad_angle > 112.5 + dpad_cardinal_priority_degrees && + dpad_angle < 157.5 - dpad_cardinal_priority_degrees + ) { + return ['up', 'left']; + } + + // West + if ( + dpad_angle > 157.5 - dpad_cardinal_priority_degrees && + dpad_angle < 202.5 + dpad_cardinal_priority_degrees + ) { + return ['left']; + } + + // South West + if ( + dpad_angle > 202.5 + dpad_cardinal_priority_degrees && + dpad_angle < 247.5 - dpad_cardinal_priority_degrees + ) { + return ['down', 'left']; + } + + // South + if ( + dpad_angle > 247.5 - dpad_cardinal_priority_degrees && + dpad_angle < 292.5 + dpad_cardinal_priority_degrees + ) { + return ['down']; + } + + // South East + if ( + dpad_angle > 292.5 + dpad_cardinal_priority_degrees && + dpad_angle < 337.5 - dpad_cardinal_priority_degrees + ) { + return ['down', 'right']; + } + + // We really *shouldn't* get here, but... in case floating point gnargles, return old directions, + // just so we don't have glitchy dropped inputs on boundaries + return old_directions; +} + +function initialize_touch(querystring) { + var touch_root_element = document.querySelector(querystring); + touch_root_element.addEventListener('touchstart', handleTouchEvent); + touch_root_element.addEventListener('touchend', handleTouchEvent); + touch_root_element.addEventListener('touchmove', handleTouchEvent); + touch_root_element.addEventListener('touchcancel', handleTouchEvent); +} + +function handleTouches(touches, event) { + // First, prune any touches that got released, and add (empty) touches for + // new identifiers + pruned_touches = {}; + for (let touch of touches) { + if (active_touches.hasOwnProperty(touch.identifier)) { + // If this touch is previously tracked, copy that info + pruned_touches[touch.identifier] = active_touches[touch.identifier]; + } else { + // Otherwise this is a new touch; initialize it accordingly + pruned_touches[touch.identifier] = { + button: null, + dpad: null, + directions: [], + }; + } + + // For buttons, first check for and handle the sticky radius. If we're still inside this, + // do not attempt to switch to a new button + if (pruned_touches[touch.identifier].button != null) { + let button_element = document.getElementById( + pruned_touches[touch.identifier].button + ); + if (!is_stuck_to_button(touch, button_element)) { + pruned_touches[touch.identifier].button = null; + } + } + + // If we have no active button for this touch, check all buttons and see if we can't find + // a new one. If so, activate it + if (pruned_touches[touch.identifier].button == null) { + for (let button_element of touch_button_elements) { + if (is_inside_button(touch, button_element)) { + pruned_touches[touch.identifier].button = button_element.id; + event.preventDefault(); + } + } + } + + // D-pads are slightly more complicated. First, if we have an active D-Pad but we've left + // its area, deactivate it + if (pruned_touches[touch.identifier].dpad != null) { + let dpad_element = document.getElementById( + pruned_touches[touch.identifier].dpad + ); + if (!is_inside_dpad(touch, dpad_element)) { + pruned_touches[touch.identifier].dpad = null; + } + } + + // If we do *not* have an active D-pad, check to see if we are inside one and, if so, + // activate it with no direction + if (pruned_touches[touch.identifier].dpad == null) { + for (let dpad_element of dpad_elements) { + if (is_inside_dpad(touch, dpad_element)) { + pruned_touches[touch.identifier].dpad = dpad_element.id; + event.preventDefault(); + } + } + } + + // Finally, with our active D-pad, collect and set the directions + if (pruned_touches[touch.identifier].dpad != null) { + let dpad_element = document.getElementById( + pruned_touches[touch.identifier].dpad + ); + let old_directions = pruned_touches[touch.identifier].directions; + pruned_touches[touch.identifier].directions = dpad_directions( + touch, + dpad_element, + old_directions + ); + event.preventDefault(); + } + } + // At this point any released touch points should not have been copied to the list, + // so swapping lists will prune them + active_touches = pruned_touches; + + process_active_touch_regions(); +} + +function clear_active_classes() { + for (let el of touch_button_elements) { + el.classList.remove('active'); + } + for (let el of dpad_elements) { + for (let direction of ['up', 'down', 'left', 'right']) { + let pad_element = document.getElementById(el.id + '_' + direction); + pad_element.classList.remove('active'); + } + } +} + +function process_active_touch_regions() { + clear_active_classes(); + for (let touch_identifier in active_touches) { + active_touch = active_touches[touch_identifier]; + if (active_touch.button != null) { + let button_element = document.getElementById(active_touch.button); + button_element.classList.add('active'); + } + if (active_touch.dpad != null) { + for (let direction of active_touch.directions) { + let pad_element = document.getElementById( + active_touch.dpad + '_' + direction + ); + pad_element.classList.add('active'); + } + } + } +} + +function handleTouchEvent(event) { + is_touch_detected = true; + handleTouches(event.touches, event); +} diff --git a/public/emu/wavy_grid_gradient.png b/public/emu/wavy_grid_gradient.png new file mode 100644 index 00000000..ee5ad4e3 Binary files /dev/null and b/public/emu/wavy_grid_gradient.png differ diff --git a/routes/routes.js b/routes/routes.js index 3b5194e2..aaf12943 100644 --- a/routes/routes.js +++ b/routes/routes.js @@ -66,27 +66,42 @@ router.get( /**/ router.get( - '/room/producer', + /^\/room\/(producer|emu)/, middlewares.assertSession, middlewares.checkToken, (req, res) => { - res.sendFile(path.join(path.resolve(), 'public/ocr/ocr.html')); + req.originalUrl; + res.sendFile( + path.join( + path.resolve(), + `public${ + /producer/.test(req.path) ? '/ocr/ocr.html' : '/emu/index.html' + }` + ) + ); } ); router.get( - '/room/u/:login/producer', + /^\/room\/u\/([^/]+)\/(producer|emu)/, middlewares.assertSession, middlewares.checkToken, async (req, res) => { - const target_user = await UserDAO.getUserByLogin(req.params.login); + const target_user = await UserDAO.getUserByLogin(req.params[0]); if (!target_user) { res.status(404).send('Target User Not found'); return; } - res.sendFile(path.join(path.resolve(), 'public/ocr/ocr.html')); + res.sendFile( + path.join( + path.resolve(), + `public${ + /producer/.test(req.path) ? '/ocr/ocr.html' : '/emu/index.html' + }` + ) + ); } ); diff --git a/routes/websocket.js b/routes/websocket.js index b8b0ff6c..0586c65a 100644 --- a/routes/websocket.js +++ b/routes/websocket.js @@ -304,7 +304,7 @@ export default function init(server, wss) { } else if (pathname.startsWith('/ws/room/admin')) { console.log(`MatchRoom: ${user.login}: Admin connected`); user.getHostRoom().setAdmin(connection); - } else if (pathname.startsWith('/ws/room/producer')) { + } else if (/^\/ws\/room\/(producer|emu)/.test(pathname)) { console.log(`PrivateRoom: ${user.login}: Producer connected`); user.setProducerConnection(connection, { match: false, @@ -342,7 +342,8 @@ export default function init(server, wss) { } /** */ - case 'producer': { + case 'producer': + case 'emu': { console.log( `MatchRoom: ${target_user.login}: Producer ${user.login} connected` ); diff --git a/views/header.ejs b/views/header.ejs index 6833e1de..67b250e0 100644 --- a/views/header.ejs +++ b/views/header.ejs @@ -63,9 +63,15 @@ Capture my gameplay into my private room + + Play online emulator into my private room + Capture my gameplay into my match room + + Play online emulator into my match room + Administer my match room diff --git a/views/settings.ejs b/views/settings.ejs index 97ea1fd8..97d355ab 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -175,7 +175,9 @@