This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,332 @@
|
||||
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();
|
||||
this.gain = this.audio.createGain();
|
||||
this.gain.gain.value = this.muted ? 0 : 0.4;
|
||||
this.gain.connect(this.audio.destination);
|
||||
} catch {
|
||||
this.audio = 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Button } from "../input/types";
|
||||
export { BUTTONS, type Button } from "../input/types";
|
||||
|
||||
/** The UI never accesses WASM memory or a vendor API. */
|
||||
export interface Emulator {
|
||||
readonly id: string;
|
||||
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
setButton(button: Button, pressed: boolean): void;
|
||||
setMuted(muted: boolean): void;
|
||||
unlockAudio(): void;
|
||||
captureState(): Uint8Array;
|
||||
restoreState(state: Uint8Array): void;
|
||||
exportBattery(): Uint8Array;
|
||||
dispose(): void;
|
||||
}
|
||||
Reference in new issue
Block a user