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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
FPS: -
+
+
+ Waiting for Audio Context... (You may need to click the page.)
+
+
+
=== Profiling Results ===
+
would go here
+
+
+
+
+
+
+ -
+ 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 @@