After a page process has opened many AudioContexts, WebKit can hand out one whose sampleRate is 0. The adapter passed that rate to binjgb, whose emulator_run_until divides by the audio frequency on every call, so the first frame trapped with "Division by zero". This failed the Day-Care cartridge comparisons on WebKit after earlier tests had created hundreds of emulators, and could crash the reference player in Safari. A context without a usable rate is now closed and the emulator runs silently at the fallback rate, as when no AudioContext can be created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
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<BinjgbModule>;
|
|
}
|
|
}
|
|
export const CORE_ID = "binjgb-c60e138da5a795ebb55e56b11b7e90024e41112c";
|
|
let scriptReady: Promise<void> | 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<void>((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<AudioBufferSourceNode>();
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
}
|