import { BUTTONS, type Button, type Emulator } from "./types"; type NativeCall = (...args: number[]) => number; interface BinjgbModule { HEAPU8: Uint8Array; [name: `_${string}`]: NativeCall; } declare global { interface Window { Binjgb?: (options: { locateFile: (path: string) => string; }) => Promise; } } export const CORE_ID = "binjgb-c60e138da5a795ebb55e56b11b7e90024e41112c"; let scriptReady: Promise | undefined; async function createModule() { const base = `${import.meta.env.BASE_URL}${import.meta.env.DEV && import.meta.env.MODE !== "reference" ? "reference/public/" : ""}vendor/binjgb/`; if (!window.Binjgb) { scriptReady ??= new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = `${base}binjgb.js`; script.onload = () => resolve(); script.onerror = () => { script.remove(); scriptReady = undefined; reject(new Error("Could not load the emulator. Reload and try again.")); }; document.head.append(script); }); await scriptReady; } if (!window.Binjgb) throw new Error("Emulator initialization failed."); return window.Binjgb({ locateFile: (path) => `${base}${path}` }); } /** Vendor-specific exports, timing, video and audio stay behind Emulator. */ export class BinjgbEmulator implements Emulator { readonly id = CORE_ID; private module?: BinjgbModule; private handle = 0; private romPointer = 0; private joypadPointer = 0; private canvas?: CanvasRenderingContext2D; private frame?: ImageData; private audio?: AudioContext; private gain?: GainNode; private sources = new Set(); private nextAudioTime = 0; private raf = 0; private lastTime = 0; private muted = true; constructor(private onError: (error: unknown) => void = () => {}) {} async load(rom: Uint8Array, canvas: HTMLCanvasElement) { this.dispose(); try { this.module = await createModule(); // Audio is optional. Failure must not block a silent game. try { this.audio = new AudioContext(); // WebKit can return a context whose sample rate is 0 (seen after a // page process has opened many); binjgb divides by the rate. if (!(this.audio.sampleRate > 0)) throw new Error("No audio sample rate."); this.gain = this.audio.createGain(); this.gain.gain.value = this.muted ? 0 : 0.4; this.gain.connect(this.audio.destination); } catch { void this.audio?.close().catch(() => {}); this.audio = undefined; this.gain = undefined; } this.romPointer = this.module._malloc(rom.length); if (!this.romPointer) throw new Error("Could not allocate ROM memory."); this.module.HEAPU8.set(rom, this.romPointer); this.handle = this.module._emulator_new_simple( this.romPointer, rom.length, this.audio?.sampleRate ?? 44100, 2048, 2, ); if (!this.handle) throw new Error("The emulator could not open this ROM."); this.joypadPointer = this.module._joypad_new(); if (!this.joypadPointer) throw new Error("Could not initialize controls."); this.module._emulator_set_default_joypad_callback( this.handle, this.joypadPointer, ); canvas.width = 160; canvas.height = 144; this.canvas = canvas.getContext("2d", { alpha: false }) ?? undefined; if (!this.canvas) throw new Error("Canvas rendering is unavailable."); this.frame = this.canvas.createImageData(160, 144); } catch (error) { this.dispose(); throw error; } } play() { if (!this.handle || this.raf) return; this.lastTime = 0; this.raf = requestAnimationFrame(this.tick); this.unlockAudio(); } pause() { cancelAnimationFrame(this.raf); this.raf = 0; this.lastTime = 0; for (const button of BUTTONS) this.setButton(button, false); for (const source of this.sources) source.stop(); this.sources.clear(); this.nextAudioTime = 0; } private tick = (now: number) => { try { const module = this.module!; const elapsed = this.lastTime ? Math.min((now - this.lastTime) / 1000, 0.05) : 1 / 60; this.lastTime = now; const target = module._emulator_get_ticks_f64(this.handle) + elapsed * 4194304; // Bound iterations so a broken core cannot freeze the page forever. let complete = false; for (let iteration = 0; iteration < 100; iteration++) { const event = module._emulator_run_until_f64(this.handle, target); if (event & 1) { const pointer = module._get_frame_buffer_ptr(this.handle); this.frame!.data.set( module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4), ); this.canvas!.putImageData(this.frame!, 0, 0); } if (event & 2) this.queueAudio(); if (event & 4) { complete = true; break; } } if (!complete) throw new Error( "Emulation stopped unexpectedly. Reload your ROM to recover.", ); this.raf = requestAnimationFrame(this.tick); } catch (error) { this.pause(); this.onError(error); } }; private queueAudio() { const audio = this.audio; if (!audio || audio.state !== "running" || this.muted || !this.gain) return; const frames = 2048; const pointer = this.module!._get_audio_buffer_ptr(this.handle); const samples = this.module!.HEAPU8.subarray(pointer, pointer + frames * 2); if (this.nextAudioTime > audio.currentTime + 0.25) return; this.nextAudioTime = Math.max( this.nextAudioTime, audio.currentTime + 0.025, ); const buffer = audio.createBuffer(2, frames, audio.sampleRate); for (let channel = 0; channel < 2; channel++) { const data = buffer.getChannelData(channel); for (let i = 0; i < frames; i++) data[i] = samples[i * 2 + channel] / 255; } const source = audio.createBufferSource(); source.buffer = buffer; source.connect(this.gain); source.onended = () => { this.sources.delete(source); source.disconnect(); }; this.sources.add(source); source.start(this.nextAudioTime); this.nextAudioTime += frames / audio.sampleRate; } setButton(button: Button, pressed: boolean) { if (!this.handle) return; const nativeName = button === "a" || button === "b" ? button.toUpperCase() : button; this.module![`_set_joyp_${nativeName}`](this.handle, Number(pressed)); } setMuted(muted: boolean) { this.muted = muted; if (this.gain) this.gain.gain.value = muted ? 0 : 0.4; if (!muted) this.unlockAudio(); } unlockAudio() { if (this.audio?.state === "suspended") void this.audio.resume().catch(() => {}); } /** Development-only observation API. This adapter is not in the native * production bundle. Used to compare translated routines with original ASM. */ readMemory(address: number): number { if ( !this.handle || !Number.isInteger(address) || address < 0 || address > 0xffff ) throw new Error("Invalid reference memory read."); return this.module!._emulator_read_mem(this.handle, address); } writeMemory(address: number, value: number) { if ( !this.handle || !Number.isInteger(address) || address < 0 || address > 0xffff || !Number.isInteger(value) || value < 0 || value > 255 ) throw new Error("Invalid reference memory write."); this.module!._emulator_write_mem(this.handle, address, value); } setProgramCounter(address: number) { if ( !this.handle || this.raf || !Number.isInteger(address) || address < 0 || address > 0xffff ) throw new Error( "Pause the reference before changing its program counter.", ); this.module!._emulator_set_PC(this.handle, address); } advanceFrames(frames: number) { if ( !this.handle || this.raf || !Number.isInteger(frames) || frames < 1 || frames > 3600 ) throw new Error("Pause the reference before bounded frame stepping."); const module = this.module!; for (let frame = 0; frame < frames; frame++) { const target = module._emulator_get_ticks_f64(this.handle) + 70224; let complete = false; for (let events = 0; events < 100; events++) { const event = module._emulator_run_until_f64(this.handle, target); if (event & 1) { const pointer = module._get_frame_buffer_ptr(this.handle); this.frame!.data.set( module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4), ); } if (event & 4) { complete = true; break; } } if (!complete) throw new Error("Reference frame budget exceeded."); } this.canvas!.putImageData(this.frame!, 0, 0); } /** Development-only: run a bounded number of CPU cycles, to align frame * sampling with a scanline when comparing against native routines. */ advanceCycles(cycles: number) { if ( !this.handle || this.raf || !Number.isInteger(cycles) || cycles < 1 || cycles > 70224 ) throw new Error("Pause the reference before bounded cycle stepping."); const module = this.module!; const target = module._emulator_get_ticks_f64(this.handle) + cycles; for (let events = 0; events < 100; events++) if (module._emulator_run_until_f64(this.handle, target) & 4) return; throw new Error("Reference cycle budget exceeded."); } private fileData( kind: "state" | "ext_ram", action: (pointer: number, bytes: Uint8Array) => void, ) { if (!this.handle || !this.module) throw new Error("Load a ROM first."); const module = this.module; const pointer = module[`_${kind}_file_data_new`](this.handle); if (!pointer) throw new Error("Could not allocate save memory."); try { const start = module._get_file_data_ptr(pointer); const length = module._get_file_data_size(pointer); action(pointer, module.HEAPU8.subarray(start, start + length)); } finally { module._file_data_delete(pointer); } } captureState() { let state!: Uint8Array; this.fileData("state", (pointer, bytes) => { if (this.module!._emulator_write_state(this.handle, pointer) !== 0) throw new Error("Could not capture the session."); state = bytes.slice(); }); return state; } restoreState(state: Uint8Array) { const current = this.captureState(); // Pinned EmulatorState: header at 0..3, cartridge-info index at 8. // Refuse an incompatible header/index before passing untrusted bytes to C. if ( state.length !== current.length || state[8] !== current[8] || !state.subarray(0, 4).every((byte, index) => byte === current[index]) ) { throw new Error("This backup has an invalid state header or size."); } this.fileData("state", (pointer, bytes) => { if (state.length !== bytes.length) throw new Error("This backup has an invalid state size."); bytes.set(state); if (this.module!._emulator_read_state(this.handle, pointer) !== 0) throw new Error("This backup contains an invalid state."); }); for (const button of BUTTONS) this.setButton(button, false); } exportBattery() { let battery!: Uint8Array; this.fileData("ext_ram", (pointer, bytes) => { if (this.module!._emulator_write_ext_ram(this.handle, pointer) !== 0) throw new Error("Could not export the in-game save."); battery = bytes.slice(); }); return battery; } dispose() { this.pause(); if (this.handle) this.module!._emulator_delete(this.handle); if (this.joypadPointer) this.module!._joypad_delete(this.joypadPointer); if (this.romPointer) this.module!._free(this.romPointer); this.handle = 0; this.joypadPointer = 0; this.romPointer = 0; void this.audio?.close().catch(() => {}); this.audio = undefined; this.gain = undefined; this.module = undefined; } }