Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+332
View File
@@ -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![`_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);
}
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;
}
}