← Files ModRetro Chromatic PluginARCHIVED FILE
dist/web-annotations/bridge.js
22.8 KB · Oct 3, 2026 · 06:38 UTC
/// <reference lib="dom" />
/**
* Annotations for the existing GB Studio Binjgb player.
* Isolated state inspection is adapted from Dominik Kundel's PR #5.
* The official player continues to own emulation, input, audio and saves.
*/
import { connectAnnotations } from "./native.js";
import { mountPlayerControls } from "./player.js";
import { connectPlayerControl } from "./remote.js";
import { annotationTargets, targetAt, viewportRect } from "./targets.js";
const OAM_RUNTIME = "69aa17be45fec5fde585af4f39ed850b5d64107ec094a6482f316fef2fe7c945";
const OAM_STATE_BYTES = 199_608;
function stateWindow(module, file) {
const size = module._get_file_data_size(file);
const pointer = module._get_file_data_ptr(file);
if (!Number.isSafeInteger(size) || size < 1 || !Number.isSafeInteger(pointer)
|| pointer < 1 || pointer > module.HEAPU8.length - size) {
throw new Error("The emulator returned an invalid annotation state buffer.");
}
return { pointer, size };
}
/** Only the SHA-256-verified runtime above has this serialized sprite layout. */
function stateOam(bytes) {
if (bytes.length !== OAM_STATE_BYTES || new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true) !== 0x6b57a7e2) {
throw new Error("The annotation state format differs from the verified runtime.");
}
// This core preserves a transient PPU OAM-read lock through restore and LCD
// disable. Its public reader derives these same four bytes from each 20-byte
// record (WASM function 21); the saved records themselves remain readable.
const oam = new Uint8Array(160);
for (let index = 0; index < 40; index++) {
const offset = 180_352 + index * 20;
oam.set([(bytes[offset] + 16) & 255, (bytes[offset + 1] + 8) & 255, bytes[offset + 2], bytes[offset + 3]], index * 4);
}
return oam;
}
/** Never changes a register, input callback or checkpoint in the live core. */
export class SnapshotReader {
module;
emulator;
context;
color;
epoch = 0;
session = crypto.randomUUID();
closed = false;
constructor(module, emulator, context, color) {
this.module = module;
this.emulator = emulator;
this.context = context;
this.color = color;
}
static async create(factory, rom, context) {
const module = await factory();
const pointer = module._malloc(rom.length);
if (!pointer)
throw new Error("Could not allocate annotation inspection memory.");
let emulator = 0;
let passedToCore = false;
try {
module.HEAPU8.set(rom, pointer);
passedToCore = true;
emulator = module._emulator_new_simple(pointer, rom.length, 48_000, 4_096, 0);
if (!emulator)
throw new Error("Could not create the annotation inspection core.");
return new SnapshotReader(module, emulator, context, (rom[0x143] & 0x80) !== 0);
}
catch (error) {
// Successful construction transfers ROM ownership to the core. On a
// rejected construction, discard this dedicated module without assuming
// whether the vendor already released its input buffer.
if (emulator)
module._emulator_delete(emulator);
else if (!passedToCore)
module._free(pointer);
throw error;
}
}
capture(player) {
if (this.closed)
throw new Error("Annotation inspection is closed.");
const live = player.module;
const inspector = this.module;
const source = live._state_file_data_new(player.e);
if (!source)
throw new Error("Could not capture the game state.");
let copy = 0;
try {
copy = inspector._state_file_data_new(this.emulator);
if (!copy)
throw new Error("Could not copy the game state.");
let sourceWindow = stateWindow(live, source);
const copyWindow = stateWindow(inspector, copy);
// The preview bootstrap marks each module only after hashing the actual
// WASM supplied to its factory. Context metadata alone is insufficient.
const verifiedOam = this.context.runtimeSha256 === OAM_RUNTIME
&& live.codexRuntimeSha256 === OAM_RUNTIME && inspector.codexRuntimeSha256 === OAM_RUNTIME;
if (sourceWindow.size !== copyWindow.size || (verifiedOam && sourceWindow.size !== OAM_STATE_BYTES)) {
throw new Error("The annotation state format differs from the player.");
}
if (live._emulator_write_state(player.e, source) !== 0)
throw new Error("Could not copy the game state.");
// Other runtime versions may replace the file buffer while serializing.
sourceWindow = stateWindow(live, source);
if (sourceWindow.size !== copyWindow.size)
throw new Error("The annotation state format differs from the player.");
const bytes = live.HEAPU8.slice(sourceWindow.pointer, sourceWindow.pointer + sourceWindow.size);
const savedOam = verifiedOam ? stateOam(bytes) : undefined;
const framePointer = live._get_frame_buffer_ptr(player.e);
const rgba = new Uint8ClampedArray(live.HEAPU8.slice(framePointer, framePointer + 160 * 144 * 4));
inspector.HEAPU8.set(bytes, copyWindow.pointer);
if (inspector._emulator_read_state(this.emulator, copy) !== 0)
throw new Error("Could not inspect the captured game state.");
const read = (address) => inspector._emulator_read_mem(this.emulator, address);
const registers = { lcdc: read(0xff40), scx: read(0xff43), scy: read(0xff42), wx: read(0xff4b), wy: read(0xff4a) };
// Debug reads obey PPU restrictions. Disable the LCD only in the copy.
inspector._emulator_write_mem(this.emulator, 0xff40, registers.lcdc & ~0x80);
const vram = [new Uint8Array(8192), new Uint8Array(8192)];
for (let bank = 0; bank < (this.color ? 2 : 1); bank++) {
if (this.color)
inspector._emulator_write_mem(this.emulator, 0xff4f, bank);
for (let offset = 0; offset < 8192; offset++)
vram[bank][offset] = read(0x8000 + offset);
}
const oam = savedOam ?? Uint8Array.from({ length: 160 }, (_, offset) => read(0xfe00 + offset));
return {
...registers, vram, oam, rgba,
id: `${this.context.romSha256.slice(0, 12)}:${this.session}:${++this.epoch}`,
rom: this.context.romPath.split("/").at(-1) ?? "Game",
sha256: this.context.romSha256,
sourceRevision: this.context.sourceRevision,
frame: Math.floor(player.ticks / 70_224),
cgb: this.color,
};
}
finally {
live._file_data_delete(source);
live._free(source);
if (copy) {
inspector._file_data_delete(copy);
inspector._free(copy);
}
}
}
dispose() {
if (this.closed)
return;
this.closed = true;
this.module._emulator_delete(this.emulator);
}
}
/** Account for the official player's optional Super Game Boy border. */
export function gameViewport(canvas) {
const outer = canvas.getBoundingClientRect();
if (!((canvas.width === 160 && canvas.height === 144) || (canvas.width === 256 && canvas.height === 224)))
return null;
// The official template uses object-fit:contain in a full-window canvas box.
const scale = Math.min(outer.width / canvas.width, outer.height / canvas.height);
const bitmap = { x: outer.x + (outer.width - canvas.width * scale) / 2, y: outer.y + (outer.height - canvas.height * scale) / 2, width: canvas.width * scale, height: canvas.height * scale };
if (canvas.width === 160)
return bitmap;
return { x: bitmap.x + 48 * scale, y: bitmap.y + 40 * scale, width: 160 * scale, height: 144 * scale };
}
/** Keep game hotkeys out of a frozen capture while preserving browser/button defaults. */
export function captureGameKey(event, keys, frozen) {
if (!frozen || event.key === "Escape" || event.key === " " || !Object.hasOwn(keys, event.key.toLowerCase()))
return;
event.stopImmediatePropagation();
const target = event.target;
const interactive = typeof target?.closest === "function" && target.closest("button,summary,input,select,textarea,[contenteditable]") !== null;
if (!interactive && !event.metaKey && !event.ctrlKey && !event.altKey)
event.preventDefault();
}
/** Compose from original captured pixels, with no changes to live WASM memory. */
export function palettePreview(snapshot, targets, palettes, compareTarget) {
const result = new Uint8ClampedArray(snapshot.rgba);
for (const target of targets) {
const colors = palettes.get(target.id);
if (!colors || target.id === compareTarget)
continue;
const replacements = new Map(target.colors.map((original, index) => [original.toLowerCase(), colors[index] ?? original]));
for (const pixel of target.pixels) {
const offset = pixel * 4;
const original = `#${Array.from(snapshot.rgba.subarray(offset, offset + 3), value => value.toString(16).padStart(2, "0")).join("")}`;
const replacement = replacements.get(original);
if (!replacement || !/^#[0-9a-f]{6}$/i.test(replacement))
continue;
for (let channel = 0; channel < 3; channel++)
result[offset + channel] = Number.parseInt(replacement.slice(1 + channel * 2, 3 + channel * 2), 16);
}
}
return result;
}
/** Entry failure does not reveal which host policy rejected a request. */
export function createAnnotationEntryFeedback(controls) {
let entryMessage;
return {
result(result) {
if (result.accepted)
return;
entryMessage = {
unsupported: "This preview cannot start annotations. Use the browser's annotation button.",
declined: "The browser did not accept the annotation request. Try its annotation button or click Annotate again.",
exception: "The annotation request failed. Reload the preview and try again.",
}[result.reason];
controls.show(entryMessage);
},
modeChanged(active) {
if (!active || entryMessage === undefined)
return;
controls.clearMessage(entryMessage);
entryMessage = undefined;
},
};
}
function currentPlayer() {
return typeof emulator === "undefined" ? null : emulator;
}
function mount(canvas, api, player, reader, controls) {
let snapshot = null;
let capturedTicks = 0;
let targets = [];
let border;
const palettes = new Map();
let compareTarget;
let disposed = false;
let ownsPause = false;
const outline = document.createElement("div");
outline.setAttribute("aria-hidden", "true");
outline.style.cssText = "position:fixed;pointer-events:none;z-index:999;outline:2px solid #f2a44a";
outline.hidden = true;
document.body.append(outline);
canvas.setAttribute("oai-annotatable", "Game screen");
const show = controls.show;
const entryFeedback = createAnnotationEntryFeedback(controls);
const current = () => snapshot && currentPlayer() === player && player.isPaused && !player.isRewinding && player.ticks === capturedTicks ? snapshot : null;
const render = () => {
const capture = current();
if (!capture)
return;
const pixels = palettePreview(capture, targets, palettes, compareTarget);
player.video.renderer.uploadTextures(new Uint8Array(pixels.buffer), border);
player.video.renderTexture();
};
const clear = () => {
if (snapshot && currentPlayer() === player) {
player.video.uploadTexture();
player.video.renderTexture();
}
snapshot = null;
targets = [];
palettes.clear();
compareTarget = undefined;
ownsPause = false;
outline.hidden = true;
annotations.clear();
controls.refresh();
};
const freeze = () => {
if (current())
return;
if (currentPlayer() !== player || player.isRewinding) {
show("Finish rewinding before annotating the game.");
return;
}
const running = !player.isPaused;
try {
// pause() enters rewind in GB Studio. Cancel the existing loop directly so
// opening the native editor cannot resume it through the blur handler.
player.windowBlur();
player.cancelAnimationFrame();
player.audio.pause();
snapshot = reader.capture(player);
ownsPause = running;
capturedTicks = player.ticks;
border = player.video.sgbBuffer ? new Uint8Array(player.video.sgbBuffer) : undefined;
targets = annotationTargets(snapshot);
palettes.clear();
compareTarget = undefined;
controls.refresh();
show("");
}
catch (error) {
snapshot = null;
ownsPause = false;
if (running && currentPlayer() === player)
player.resume();
show("Object annotations are unavailable for this frame.");
console.warn("Game annotation capture failed", error);
}
};
const colorControls = (id) => {
const target = current() ? targets.find(target => target.id === id) : undefined;
if (!target)
return [];
return target.colors.map((color, index) => ({ type: "color", label: `Color ${index + 1}`, callback: `palette-${index}`, reference: `game-object-color/${index + 1}`, currentValue: palettes.get(id)?.[index] ?? color }));
};
const control = (event) => {
const capture = current();
const id = event.virtualTarget?.targetId;
const target = id ? targets.find(target => target.id === id) : undefined;
const match = /^palette-([0-3])$/.exec(event.callback);
if (!capture || !target || !match)
return;
const index = Number(match[1]);
if (!target.colors[index])
return;
if (event.action === "preview-original")
compareTarget = target.id;
else {
const colors = [...(palettes.get(target.id) ?? target.colors)];
if (event.action === "reset")
colors[index] = target.colors[index];
else if (typeof event.value === "string" && /^#[0-9a-f]{6}$/i.test(event.value))
colors[index] = event.value.toLowerCase();
else
return;
palettes.set(target.id, colors);
compareTarget = undefined;
annotations.refreshControls();
}
render();
};
const annotations = connectAnnotations(canvas, api, {
freeze, snapshot: current, targetAt: (x, y) => targetAt(targets, x, y),
target: id => current() ? targets.find(target => target.id === id) : undefined,
viewport: () => gameViewport(canvas), controls: colorControls, control,
modeChanged(active) {
entryFeedback.modeChanged(active);
if (!active && snapshot) {
const resume = ownsPause && current() !== null;
clear();
if (resume) {
player.windowBlur();
player.resume();
}
}
controls.refresh();
},
select(id, hover) {
const target = current() ? targets.find(target => target.id === (hover ?? id)) : undefined;
const viewport = gameViewport(canvas);
outline.hidden = !target || !viewport;
if (target && viewport) {
const rect = viewportRect(target.rect, viewport);
Object.assign(outline.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` });
}
},
});
if (!annotations.available) {
annotations.dispose();
reader.dispose();
outline.remove();
return () => { };
}
canvas.dataset.codexAnnotations = "ready";
controls.setAnnotations({
active: () => annotations.hasPendingEntry() || annotations.isActive() === true || current() !== null,
clear,
exit: () => annotations.exitSelection(),
start() {
const result = annotations.startSelection();
entryFeedback.result(result);
if (result.accepted && annotations.isActive() === true)
entryFeedback.modeChanged(true);
},
});
const blockGameInput = (event) => captureGameKey(event, player.keyFuncs, current() !== null);
window.addEventListener("keydown", blockGameInput, true);
window.addEventListener("keyup", blockGameInput, true);
const reposition = () => { outline.hidden = true; annotations.invalidate(); };
const resize = new ResizeObserver(reposition);
resize.observe(canvas);
window.addEventListener("scroll", reposition, { passive: true });
const interval = window.setInterval(() => { if (snapshot && !current())
clear(); }, 100);
return () => {
if (disposed)
return;
disposed = true;
const restorePlayback = ownsPause && current() !== null;
clear();
annotations.dispose();
reader.dispose();
resize.disconnect();
window.clearInterval(interval);
window.removeEventListener("keydown", blockGameInput, true);
window.removeEventListener("keyup", blockGameInput, true);
window.removeEventListener("scroll", reposition);
controls.setAnnotations(null);
outline.remove();
delete canvas.dataset.codexAnnotations;
if (restorePlayback && currentPlayer() === player)
player.resume();
};
}
async function attach(controls) {
const canvas = document.querySelector("#mainCanvas");
const api = document.oai?.annotation;
const player = currentPlayer();
const raw = document.getElementById("codex-web-annotations-context")?.textContent;
if (!canvas || !api?.registerSurface || !player?.e || !raw || typeof Binjgb !== "function" || !gameViewport(canvas))
return null;
const context = JSON.parse(raw);
if (!/^[a-f0-9]{64}$/.test(context.romSha256) || typeof context.romPath !== "string" || typeof context.sourceRevision !== "string")
throw new Error("Invalid game annotation context.");
const url = new URL(context.romPath, location.href);
const base = new URL(".", location.href);
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname))
throw new Error("Annotation cartridge is outside this preview.");
const response = await fetch(url);
if (!response.ok)
throw new Error("Annotation cartridge is unavailable.");
const rom = new Uint8Array(await response.arrayBuffer());
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", rom)), value => value.toString(16).padStart(2, "0")).join("");
if (digest !== context.romSha256)
throw new Error("The game build changed. Reopen its preview before annotating.");
const loaded = player.module.HEAPU8.subarray(player.romDataPtr, player.romDataPtr + rom.length);
if (loaded.length !== rom.length || !loaded.every((value, index) => value === rom[index]))
throw new Error("The player cartridge differs from the annotation build.");
const reader = await SnapshotReader.create(() => Binjgb({ locateFile: name => new URL(`js/${name}`, base).href }), rom, context);
if (currentPlayer() !== player) {
reader.dispose();
return null;
}
try {
return mount(canvas, api, player, reader, controls);
}
catch (error) {
reader.dispose();
throw error;
}
}
if (typeof document !== "undefined" && typeof window !== "undefined") {
let dispose = null;
let preparing = false;
let active = true;
let boundPlayer = null;
let controls = null;
let disconnectPlayer = null;
let controlsPlayer = null;
let failedPlayer = null;
const tryAttach = async () => {
if (!active || preparing)
return;
const player = currentPlayer();
if (controls && player !== controlsPlayer) {
dispose?.();
dispose = null;
disconnectPlayer?.();
disconnectPlayer = null;
controls.dispose();
controls = null;
}
if (!controls && player?.e) {
const canvas = document.querySelector("#mainCanvas");
const raw = document.getElementById("codex-web-annotations-context")?.textContent;
if (canvas && raw && gameViewport(canvas)) {
try {
const context = JSON.parse(raw);
if (!/^[a-f0-9]{64}$/.test(context.romSha256) || typeof context.romPath !== "string" || typeof context.sourceRevision !== "string")
throw new Error("Invalid game preview context.");
controls = mountPlayerControls(canvas, player, context, () => currentPlayer() === player);
controlsPlayer = player;
const mountedControls = controls;
disconnectPlayer = connectPlayerControl({ context, player, controls: mountedControls, current: () => active && controls === mountedControls && currentPlayer() === player });
}
catch (error) {
console.warn("Game preview controls unavailable", error);
}
}
}
if (dispose && player === boundPlayer)
return;
if (dispose) {
dispose();
dispose = null;
}
if (!player || !controls || player === failedPlayer)
return;
preparing = true;
try {
const mounted = await attach(controls);
if (!active)
mounted?.();
else {
dispose = mounted;
boundPlayer = mounted ? player : null;
}
}
catch (error) {
failedPlayer = player;
console.warn("Game annotations unavailable", error);
}
finally {
preparing = false;
}
};
// The official player and the host API may initialize after this module.
window.setInterval(() => { void tryAttach(); }, 500);
window.addEventListener("pagehide", () => { active = false; disconnectPlayer?.(); disconnectPlayer = null; dispose?.(); dispose = null; controls?.dispose(); controls = null; controlsPlayer = null; });
window.addEventListener("pageshow", () => { active = true; failedPlayer = null; void tryAttach(); });
void tryAttach();
}
//# sourceMappingURL=bridge.js.mapSHA-256: df5309d25d142db2271a7053a26d69144ebf5ddc772b9f2c9d9d90d1d218545f