/* * Copyright (C) 2020 Ben Smith * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. * * * Some code from GB-Studio, see LICENSE.gbstudio */ "use strict"; const PALETTES = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, ]; // It's probably OK to leave these alone. But you can tweak them to get better // rewind performance. const REWIND_FRAMES_PER_BASE_STATE = 45; // How many delta frames until keyframe const REWIND_BUFFER_CAPACITY = 4 * 1024 * 1024; // Total rewind capacity const REWIND_FACTOR = 1.5; // How fast is rewind compared to normal speed const REWIND_UPDATE_MS = 16; // Rewind setInterval rate // Probably OK to leave these alone too. const AUDIO_FRAMES = 4096; // Number of audio frames pushed per buffer const AUDIO_LATENCY_SEC = 0.1; const MAX_UPDATE_SEC = 5 / 60; // Max. time to run emulator per step (== 5 frames) // Constants const RESULT_OK = 0; const RESULT_ERROR = 1; const SCREEN_WIDTH = 160; const SCREEN_HEIGHT = 144; const CPU_TICKS_PER_SECOND = 4194304; const EVENT_NEW_FRAME = 1; const EVENT_AUDIO_BUFFER_FULL = 2; const EVENT_UNTIL_TICKS = 4; const $ = document.querySelector.bind(document); let emulator = null; const controllerEl = $('#controller'); const dpadEl = $('#controller_dpad'); const selectEl = $('#controller_select'); const startEl = $('#controller_start'); const bEl = $('#controller_b'); const aEl = $('#controller_a'); const binjgbPromise = Binjgb(); // Extract stuff from the vue.js implementation in demo.js. class VM { constructor() { this.ticks = 0; this.extRamUpdated = false; this.paused_ = false; this.volume = 0.5; this.palIdx = DEFAULT_PALETTE_IDX; this.rewind = { minTicks: 0, maxTicks: 0, }; setInterval(() => { if (this.extRamUpdated) { this.updateExtRam(); this.extRamUpdated = false; } }, 1000); } get paused() { return this.paused_; } set paused(newPaused) { let oldPaused = this.paused_; this.paused_ = newPaused; if (!emulator) return; if (newPaused == oldPaused) return; if (newPaused) { emulator.pause(); this.ticks = emulator.ticks; this.rewind.minTicks = emulator.rewind.oldestTicks; this.rewind.maxTicks = emulator.rewind.newestTicks; } else { emulator.resume(); } } togglePause() { this.paused = !this.paused; } updateExtRam() { if (!emulator) return; const extram = emulator.getExtRam(); localStorage.setItem('extram', JSON.stringify(Array.from(extram))); } }; const vm = new VM(); // Load a ROM. (async function go() { let response = await fetch(ROM_FILENAME); let romBuffer = await response.arrayBuffer(); const extRam = new Uint8Array(JSON.parse(localStorage.getItem('extram'))); Emulator.start(await binjgbPromise, romBuffer, extRam); emulator.setBuiltinPalette(vm.palIdx); })(); // Copied from demo.js function makeWasmBuffer(module, ptr, size) { return new Uint8Array(module.HEAP8.buffer, ptr, size); } class Emulator { static start(module, romBuffer, extRamBuffer) { Emulator.stop(); emulator = new Emulator(module, romBuffer, extRamBuffer); emulator.run(); } static stop() { if (emulator) { emulator.destroy(); emulator = null; } } constructor(module, romBuffer, extRamBuffer) { this.module = module; // Align size up to 32k. const size = (romBuffer.byteLength + 0x7fff) & ~0x7fff; this.romDataPtr = this.module._malloc(size); makeWasmBuffer(this.module, this.romDataPtr, size) .fill(0) .set(new Uint8Array(romBuffer)); this.e = this.module._emulator_new_simple( this.romDataPtr, size, Audio.ctx.sampleRate, AUDIO_FRAMES, CGB_COLOR_CURVE); if (this.e == 0) { throw new Error('Invalid ROM.'); } this.audio = new Audio(module, this.e); this.video = new Video(module, this.e, $('canvas')); this.rewind = new Rewind(module, this.e); this.rewindIntervalId = 0; this.lastRafSec = 0; this.leftoverTicks = 0; this.fps = 60; this.fastForward = false; if (extRamBuffer) { this.loadExtRam(extRamBuffer); } this.bindKeys(); this.bindTouch(); this.touchEnabled = 'ontouchstart' in document.documentElement; this.updateOnscreenGamepad(); } destroy() { this.unbindTouch(); this.unbindKeys(); this.cancelAnimationFrame(); clearInterval(this.rewindIntervalId); this.rewind.destroy(); this.module._emulator_delete(this.e); this.module._free(this.romDataPtr); } withNewFileData(cb) { const fileDataPtr = this.module._ext_ram_file_data_new(this.e); const buffer = makeWasmBuffer( this.module, this.module._get_file_data_ptr(fileDataPtr), this.module._get_file_data_size(fileDataPtr)); const result = cb(fileDataPtr, buffer); this.module._file_data_delete(fileDataPtr); return result; } loadExtRam(extRamBuffer) { this.withNewFileData((fileDataPtr, buffer) => { if (buffer.byteLength === extRamBuffer.byteLength) { buffer.set(new Uint8Array(extRamBuffer)); this.module._emulator_read_ext_ram(this.e, fileDataPtr); } }); } getExtRam() { return this.withNewFileData((fileDataPtr, buffer) => { this.module._emulator_write_ext_ram(this.e, fileDataPtr); return new Uint8Array(buffer); }); } get isPaused() { return this.rafCancelToken === null; } pause() { if (!this.isPaused) { this.cancelAnimationFrame(); this.audio.pause(); this.beginRewind(); } } resume() { if (this.isPaused) { this.endRewind(); this.requestAnimationFrame(); this.audio.resume(); } } setBuiltinPalette(palIdx) { this.module._emulator_set_builtin_palette(this.e, PALETTES[palIdx]); } get isRewinding() { return ENABLE_REWIND && this.rewind.isRewinding; } beginRewind() { if (!ENABLE_REWIND) { return; } this.rewind.beginRewind(); } rewindToTicks(ticks) { if (!ENABLE_REWIND) { return; } if (this.rewind.rewindToTicks(ticks)) { this.runUntil(ticks); this.video.renderTexture(); } } endRewind() { if (!ENABLE_REWIND) { return; } this.rewind.endRewind(); this.lastRafSec = 0; this.leftoverTicks = 0; this.audio.startSec = 0; } set autoRewind(enabled) { if (!ENABLE_REWIND) { return; } if (enabled) { this.rewindIntervalId = setInterval(() => { const oldest = this.rewind.oldestTicks; const start = this.ticks; const delta = REWIND_FACTOR * REWIND_UPDATE_MS / 1000 * CPU_TICKS_PER_SECOND; const rewindTo = Math.max(oldest, start - delta); this.rewindToTicks(rewindTo); vm.ticks = emulator.ticks; }, REWIND_UPDATE_MS); } else { clearInterval(this.rewindIntervalId); this.rewindIntervalId = 0; } } requestAnimationFrame() { this.rafCancelToken = requestAnimationFrame(this.rafCallback.bind(this)); } cancelAnimationFrame() { cancelAnimationFrame(this.rafCancelToken); this.rafCancelToken = null; } run() { this.requestAnimationFrame(); } get ticks() { return this.module._emulator_get_ticks_f64(this.e); } runUntil(ticks) { while (true) { const event = this.module._emulator_run_until_f64(this.e, ticks); if (event & EVENT_NEW_FRAME) { this.rewind.pushBuffer(); this.video.uploadTexture(); } if ((event & EVENT_AUDIO_BUFFER_FULL) && !this.isRewinding) { this.audio.pushBuffer(); } if (event & EVENT_UNTIL_TICKS) { break; } } if (this.module._emulator_was_ext_ram_updated(this.e)) { vm.extRamUpdated = true; } } rafCallback(startMs) { this.requestAnimationFrame(); let deltaSec = 0; if (!this.isRewinding) { const startSec = startMs / 1000; deltaSec = Math.max(startSec - (this.lastRafSec || startSec), 0); const startTimeMs = performance.now(); const deltaTicks = Math.min(deltaSec, MAX_UPDATE_SEC) * CPU_TICKS_PER_SECOND; let runUntilTicks = this.ticks + deltaTicks - this.leftoverTicks; this.runUntil(runUntilTicks); const deltaTimeMs = performance.now() - startTimeMs; const deltaTimeSec = deltaTimeMs / 1000; if (this.fastForward) { // Estimate how much faster we can run in fast-forward, keeping the // same rAF update rate. const speedUp = (deltaTicks / CPU_TICKS_PER_SECOND) / deltaTimeSec; const extraFrames = Math.floor(speedUp - deltaTimeSec); const extraTicks = extraFrames * deltaTicks; runUntilTicks = this.ticks + extraTicks - this.leftoverTicks; this.runUntil(runUntilTicks); } this.leftoverTicks = (this.ticks - runUntilTicks) | 0; this.lastRafSec = startSec; } const lerp = (from, to, alpha) => (alpha * from) + (1 - alpha) * to; this.fps = lerp(this.fps, Math.min(1 / deltaSec, 10000), 0.3); this.video.renderTexture(); } updateOnscreenGamepad() { $('#controller').style.display = this.touchEnabled ? 'block' : 'none'; } bindTouch() { this.touchFuncs = { 'controller_b': this.setJoypB.bind(this), 'controller_a': this.setJoypA.bind(this), 'controller_start': this.setJoypStart.bind(this), 'controller_select': this.setJoypSelect.bind(this), }; this.boundButtonTouchStart = this.buttonTouchStart.bind(this); this.boundButtonTouchEnd = this.buttonTouchEnd.bind(this); selectEl.addEventListener('touchstart', this.boundButtonTouchStart); selectEl.addEventListener('touchend', this.boundButtonTouchEnd); startEl.addEventListener('touchstart', this.boundButtonTouchStart); startEl.addEventListener('touchend', this.boundButtonTouchEnd); bEl.addEventListener('touchstart', this.boundButtonTouchStart); bEl.addEventListener('touchend', this.boundButtonTouchEnd); aEl.addEventListener('touchstart', this.boundButtonTouchStart); aEl.addEventListener('touchend', this.boundButtonTouchEnd); this.boundDpadTouchStartMove = this.dpadTouchStartMove.bind(this); this.boundDpadTouchEnd = this.dpadTouchEnd.bind(this); dpadEl.addEventListener('touchstart', this.boundDpadTouchStartMove); dpadEl.addEventListener('touchmove', this.boundDpadTouchStartMove); dpadEl.addEventListener('touchend', this.boundDpadTouchEnd); this.boundTouchRestore = this.touchRestore.bind(this); window.addEventListener('touchstart', this.boundTouchRestore); } unbindTouch() { selectEl.removeEventListener('touchstart', this.boundButtonTouchStart); selectEl.removeEventListener('touchend', this.boundButtonTouchEnd); startEl.removeEventListener('touchstart', this.boundButtonTouchStart); startEl.removeEventListener('touchend', this.boundButtonTouchEnd); bEl.removeEventListener('touchstart', this.boundButtonTouchStart); bEl.removeEventListener('touchend', this.boundButtonTouchEnd); aEl.removeEventListener('touchstart', this.boundButtonTouchStart); aEl.removeEventListener('touchend', this.boundButtonTouchEnd); dpadEl.removeEventListener('touchstart', this.boundDpadTouchStartMove); dpadEl.removeEventListener('touchmove', this.boundDpadTouchStartMove); dpadEl.removeEventListener('touchend', this.boundDpadTouchEnd); window.removeEventListener('touchstart', this.boundTouchRestore); } buttonTouchStart(event) { if (event.currentTarget.id in this.touchFuncs) { this.touchFuncs[event.currentTarget.id](true); event.currentTarget.classList.add('btnPressed'); event.preventDefault(); } } buttonTouchEnd(event) { if (event.currentTarget.id in this.touchFuncs) { this.touchFuncs[event.currentTarget.id](false); event.currentTarget.classList.remove('btnPressed'); event.preventDefault(); } } dpadTouchStartMove(event) { const rect = event.currentTarget.getBoundingClientRect(); const x = (2 * (event.targetTouches[0].clientX - rect.left)) / rect.width - 1; const y = (2 * (event.targetTouches[0].clientY - rect.top)) / rect.height - 1; if (Math.abs(x) > OSGP_DEADZONE) { if (y > x && y < -x) { this.setJoypLeft(true); this.setJoypRight(false); } else if (y < x && y > -x) { this.setJoypLeft(false); this.setJoypRight(true); } } else { this.setJoypLeft(false); this.setJoypRight(false); } if (Math.abs(y) > OSGP_DEADZONE) { if (x > y && x < -y) { this.setJoypUp(true); this.setJoypDown(false); } else if (x < y && x > -y) { this.setJoypUp(false); this.setJoypDown(true); } } else { this.setJoypUp(false); this.setJoypDown(false); } event.preventDefault(); } dpadTouchEnd(event) { this.setJoypLeft(false); this.setJoypRight(false); this.setJoypUp(false); this.setJoypDown(false); event.preventDefault(); } touchRestore() { this.touchEnabled = true; this.updateOnscreenGamepad(); } bindKeys() { this.keyFuncs = { 'ArrowDown': this.setJoypDown.bind(this), 'ArrowLeft': this.setJoypLeft.bind(this), 'ArrowRight': this.setJoypRight.bind(this), 'ArrowUp': this.setJoypUp.bind(this), 'KeyZ': this.setJoypB.bind(this), 'KeyX': this.setJoypA.bind(this), 'Enter': this.setJoypStart.bind(this), 'Tab': this.setJoypSelect.bind(this), 'Backspace': this.keyRewind.bind(this), 'Space': this.keyPause.bind(this), 'BracketLeft': this.keyPrevPalette.bind(this), 'BracketRight': this.keyNextPalette.bind(this), 'ShiftLeft': this.setFastForward.bind(this), }; this.boundKeyDown = this.keyDown.bind(this); this.boundKeyUp = this.keyUp.bind(this); window.addEventListener('keydown', this.boundKeyDown); window.addEventListener('keyup', this.boundKeyUp); } unbindKeys() { window.removeEventListener('keydown', this.boundKeyDown); window.removeEventListener('keyup', this.boundKeyUp); } keyDown(event) { if (event.code in this.keyFuncs) { if (this.touchEnabled) { this.touchEnabled = false; this.updateOnscreenGamepad(); } this.keyFuncs[event.code](true); event.preventDefault(); } } keyUp(event) { if (event.code in this.keyFuncs) { this.keyFuncs[event.code](false); event.preventDefault(); } } keyRewind(isKeyDown) { if (!ENABLE_REWIND) { return; } if (this.isRewinding !== isKeyDown) { if (isKeyDown) { vm.paused = true; this.autoRewind = true; } else { this.autoRewind = false; vm.paused = false; } } } keyPause(isKeyDown) { if (!ENABLE_PAUSE) { return; } if (isKeyDown) vm.togglePause(); } keyPrevPalette(isKeyDown) { if (!ENABLE_SWITCH_PALETTES) { return; } if (isKeyDown) { vm.palIdx = (vm.palIdx + PALETTES.length - 1) % PALETTES.length; emulator.setBuiltinPalette(vm.palIdx); } } keyNextPalette(isKeyDown) { if (!ENABLE_SWITCH_PALETTES) { return; } if (isKeyDown) { vm.palIdx = (vm.palIdx + 1) % PALETTES.length; emulator.setBuiltinPalette(vm.palIdx); } } setFastForward(isKeyDown) { if (!ENABLE_FAST_FORWARD) { return; } this.fastForward = isKeyDown; } setJoypDown(set) { this.module._set_joyp_down(this.e, set); } setJoypUp(set) { this.module._set_joyp_up(this.e, set); } setJoypLeft(set) { this.module._set_joyp_left(this.e, set); } setJoypRight(set) { this.module._set_joyp_right(this.e, set); } setJoypSelect(set) { this.module._set_joyp_select(this.e, set); } setJoypStart(set) { this.module._set_joyp_start(this.e, set); } setJoypB(set) { this.module._set_joyp_B(this.e, set); } setJoypA(set) { this.module._set_joyp_A(this.e, set); } } class Audio { constructor(module, e) { this.started = false; this.module = module; this.buffer = makeWasmBuffer( this.module, this.module._get_audio_buffer_ptr(e), this.module._get_audio_buffer_capacity(e)); this.startSec = 0; this.resume(); this.boundStartPlayback = this.startPlayback.bind(this); window.addEventListener('keydown', this.boundStartPlayback, true); window.addEventListener('click', this.boundStartPlayback, true); window.addEventListener('touchend', this.boundStartPlayback, true); } startPlayback() { window.removeEventListener('touchend', this.boundStartPlayback, true); window.removeEventListener('keydown', this.boundStartPlayback, true); window.removeEventListener('click', this.boundStartPlayback, true); this.started = true; this.resume(); } get sampleRate() { return Audio.ctx.sampleRate; } pushBuffer() { if (!this.started) { return; } const nowSec = Audio.ctx.currentTime; const nowPlusLatency = nowSec + AUDIO_LATENCY_SEC; const volume = vm.volume; this.startSec = (this.startSec || nowPlusLatency); if (this.startSec >= nowSec) { const buffer = Audio.ctx.createBuffer(2, AUDIO_FRAMES, this.sampleRate); const channel0 = buffer.getChannelData(0); const channel1 = buffer.getChannelData(1); for (let i = 0; i < AUDIO_FRAMES; i++) { channel0[i] = this.buffer[2 * i] * volume / 255; channel1[i] = this.buffer[2 * i + 1] * volume / 255; } const bufferSource = Audio.ctx.createBufferSource(); bufferSource.buffer = buffer; bufferSource.connect(Audio.ctx.destination); bufferSource.start(this.startSec); const bufferSec = AUDIO_FRAMES / this.sampleRate; this.startSec += bufferSec; } else { console.log( 'Resetting audio (' + this.startSec.toFixed(2) + ' < ' + nowSec.toFixed(2) + ')'); this.startSec = nowPlusLatency; } } pause() { if (!this.started) { return; } Audio.ctx.suspend(); } resume() { if (!this.started) { return; } Audio.ctx.resume(); } } Audio.ctx = new AudioContext; class Video { constructor(module, e, el) { this.module = module; // iPhone Safari doesn't upscale using image-rendering: pixelated on webgl // canvases. See https://bugs.webkit.org/show_bug.cgi?id=193895. // For now, default to Canvas2D. if (window.navigator.userAgent.match(/iPhone|iPad/)) { this.renderer = new Canvas2DRenderer(el); } else { try { this.renderer = new WebGLRenderer(el); } catch (error) { console.log(`Error creating WebGLRenderer: ${error}`); this.renderer = new Canvas2DRenderer(el); } } this.buffer = makeWasmBuffer( this.module, this.module._get_frame_buffer_ptr(e), this.module._get_frame_buffer_size(e)); } uploadTexture() { this.renderer.uploadTexture(this.buffer); } renderTexture() { this.renderer.renderTexture(); } } class Canvas2DRenderer { constructor(el) { this.ctx = el.getContext('2d'); this.imageData = this.ctx.createImageData(el.width, el.height); } renderTexture() { this.ctx.putImageData(this.imageData, 0, 0); } uploadTexture(buffer) { this.imageData.data.set(buffer); } } class WebGLRenderer { constructor(el) { const gl = this.gl = el.getContext('webgl', {preserveDrawingBuffer: true}); if (gl === null) { throw new Error('unable to create webgl context'); } const w = SCREEN_WIDTH / 256; const h = SCREEN_HEIGHT / 256; const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 0, h, +1, -1, w, h, -1, +1, 0, 0, +1, +1, w, 0, ]), gl.STATIC_DRAW); const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, 256, 256, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); function compileShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(`compileShader failed: ${gl.getShaderInfoLog(shader)}`); } return shader; } const vertexShader = compileShader(gl.VERTEX_SHADER, `attribute vec2 aPos; attribute vec2 aTexCoord; varying highp vec2 vTexCoord; void main(void) { gl_Position = vec4(aPos, 0.0, 1.0); vTexCoord = aTexCoord; }`); const fragmentShader = compileShader(gl.FRAGMENT_SHADER, `varying highp vec2 vTexCoord; uniform sampler2D uSampler; void main(void) { gl_FragColor = texture2D(uSampler, vTexCoord); }`); const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error(`program link failed: ${gl.getProgramInfoLog(program)}`); } gl.useProgram(program); const aPos = gl.getAttribLocation(program, 'aPos'); const aTexCoord = gl.getAttribLocation(program, 'aTexCoord'); const uSampler = gl.getUniformLocation(program, 'uSampler'); gl.enableVertexAttribArray(aPos); gl.enableVertexAttribArray(aTexCoord); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, gl.FALSE, 16, 0); gl.vertexAttribPointer(aTexCoord, 2, gl.FLOAT, gl.FALSE, 16, 8); gl.uniform1i(uSampler, 0); } renderTexture() { this.gl.clearColor(0.5, 0.5, 0.5, 1.0); this.gl.clear(this.gl.COLOR_BUFFER_BIT); this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4); } uploadTexture(buffer) { this.gl.texSubImage2D( this.gl.TEXTURE_2D, 0, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, this.gl.RGBA, this.gl.UNSIGNED_BYTE, buffer); } } class Rewind { constructor(module, e) { this.module = module; this.e = e; this.joypadBufferPtr = this.module._joypad_new(); this.statePtr = 0; this.bufferPtr = this.module._rewind_new_simple( e, REWIND_FRAMES_PER_BASE_STATE, REWIND_BUFFER_CAPACITY); this.module._emulator_set_default_joypad_callback(e, this.joypadBufferPtr); } destroy() { this.module._rewind_delete(this.bufferPtr); this.module._joypad_delete(this.joypadBufferPtr); } get oldestTicks() { return this.module._rewind_get_oldest_ticks_f64(this.bufferPtr); } get newestTicks() { return this.module._rewind_get_newest_ticks_f64(this.bufferPtr); } pushBuffer() { if (!this.isRewinding) { this.module._rewind_append(this.bufferPtr, this.e); } } get isRewinding() { return this.statePtr !== 0; } beginRewind() { if (this.isRewinding) return; this.statePtr = this.module._rewind_begin(this.e, this.bufferPtr, this.joypadBufferPtr); } rewindToTicks(ticks) { if (!this.isRewinding) return; return this.module._rewind_to_ticks_wrapper(this.statePtr, ticks) === RESULT_OK; } endRewind() { if (!this.isRewinding) return; this.module._emulator_set_default_joypad_callback( this.e, this.joypadBufferPtr); this.module._rewind_end(this.statePtr); this.statePtr = 0; } }