@wasm-gaming/mgba-wasm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,967 @@
1
+ import type {
2
+ AssetData,
3
+ EngineConfig,
4
+ EngineEvent,
5
+ EngineInstance,
6
+ InputPreset,
7
+ KeyMap,
8
+ } from '@wasm-gaming/engine-specs';
9
+ import { manifest } from './mgba.manifest.js';
10
+ import {
11
+ bindConfig,
12
+ createSettingsStore,
13
+ type MgbaAppliers,
14
+ type MgbaConfig,
15
+ } from './mgba.config.js';
16
+ import {
17
+ coerceOptionValue,
18
+ DEFAULT_MGBA_OPTIONS,
19
+ MGBA_ENGINE_OPTIONS,
20
+ MGBA_GB_MODEL_VALUES,
21
+ MGBA_LOG_LEVEL_IDS,
22
+ MGBA_PLATFORM_IDS,
23
+ toEscMenuGroups,
24
+ type EscMenuGroup,
25
+ type MgbaGbModel,
26
+ type MgbaLogLevel,
27
+ type MgbaOptions,
28
+ type MgbaOptionValue,
29
+ type MgbaSystem,
30
+ } from './mgba.options.js';
31
+ import { extractRom } from './mgba.zip.js';
32
+
33
+ export { manifest };
34
+
35
+ /**
36
+ * Shape of the Emscripten module produced by scripts/build-mgba.sh
37
+ * (`-sMODULARIZE -sEXPORT_NAME=createMgbaModule`, heap views exported).
38
+ * Every entry point is a flat C export from scripts/shim/mgba_shim.c — no SDL,
39
+ * no main loop, no ASYNCIFY: the SDK drives one `_mgbawasm_run_frame()` per
40
+ * emulated frame.
41
+ */
42
+ type MgbaModule = {
43
+ HEAPU8: Uint8Array;
44
+ HEAP16: Int16Array;
45
+ _malloc(size: number): number;
46
+ _free(ptr: number): void;
47
+ UTF8ToString(ptr: number): string;
48
+ stringToUTF8(str: string, ptr: number, maxBytes: number): void;
49
+ lengthBytesUTF8(str: string): number;
50
+ _mgbawasm_init(): void;
51
+ _mgbawasm_set_log_level(level: number): void;
52
+ _mgbawasm_load(
53
+ romPtr: number,
54
+ romBytes: number,
55
+ biosPtr: number,
56
+ biosBytes: number,
57
+ platform: number,
58
+ gbModelPtr: number,
59
+ skipBios: number,
60
+ ): number;
61
+ _mgbawasm_unload(): void;
62
+ _mgbawasm_platform(): number;
63
+ _mgbawasm_reset(): void;
64
+ _mgbawasm_run_frame(): void;
65
+ _mgbawasm_video_ptr(): number;
66
+ _mgbawasm_video_width(): number;
67
+ _mgbawasm_video_height(): number;
68
+ _mgbawasm_frame_counter(): number;
69
+ _mgbawasm_framerate_micro(): number;
70
+ _mgbawasm_sample_rate(): number;
71
+ _mgbawasm_audio_available(): number;
72
+ _mgbawasm_read_audio(ptr: number, frames: number): number;
73
+ _mgbawasm_set_keys(keys: number): void;
74
+ _mgbawasm_state_size(): number;
75
+ _mgbawasm_state_save(ptr: number): number;
76
+ _mgbawasm_state_load(ptr: number): number;
77
+ _mgbawasm_sram_save(): number;
78
+ _mgbawasm_sram_ptr(): number;
79
+ _mgbawasm_sram_load(ptr: number, bytes: number): number;
80
+ _mgbawasm_has_bios(): number;
81
+ // Live `mCoreConfig` writes. Optional so an older mgba.wasm still loads —
82
+ // the settings they back are then reported as unsupported and drop out of
83
+ // the menu rather than erroring.
84
+ _mgbawasm_set_idle_optimization?(mode: number): void;
85
+ _mgbawasm_set_allow_opposing_directions?(allow: number): void;
86
+ };
87
+
88
+ type MgbaModuleFactory = (overrides: Record<string, unknown>) => Promise<MgbaModule>;
89
+
90
+ /** `enum mPlatform` values, as reported by `_mgbawasm_platform()`. */
91
+ const PLATFORM_GBA = 0;
92
+ const PLATFORM_GB = 1;
93
+
94
+ /** Numeric ids `mgbawasm_set_idle_optimization()` takes. */
95
+ const IDLE_IDS: Record<string, number> = { ignore: 0, remove: 1, detect: 2 };
96
+
97
+ /**
98
+ * Bit index of each control, matching `enum GBAKey` in mGBA. The Game Boy core
99
+ * numbers its first eight keys the same way, so one mask drives both; the L/R
100
+ * bits are ignored while a Game Boy ROM is running.
101
+ */
102
+ const KEY_BITS: Record<string, number> = {
103
+ a: 0,
104
+ b: 1,
105
+ select: 2,
106
+ start: 3,
107
+ right: 4,
108
+ left: 5,
109
+ up: 6,
110
+ down: 7,
111
+ r: 8,
112
+ l: 9,
113
+ };
114
+
115
+ /**
116
+ * Default keyboard bindings (KeyboardEvent.code → control). X/Z sit where A/B
117
+ * do on the handheld: A is the right-hand button, B the left one.
118
+ */
119
+ const DEFAULT_KEYMAP: Record<string, string> = {
120
+ 'p1.up': 'ArrowUp',
121
+ 'p1.down': 'ArrowDown',
122
+ 'p1.left': 'ArrowLeft',
123
+ 'p1.right': 'ArrowRight',
124
+ 'p1.a': 'KeyX',
125
+ 'p1.b': 'KeyZ',
126
+ 'p1.l': 'KeyA',
127
+ 'p1.r': 'KeyS',
128
+ 'p1.start': 'Enter',
129
+ 'p1.select': 'ShiftRight',
130
+ };
131
+
132
+ /**
133
+ * AudioWorklet processor: an SPSC float ring fed int16 chunks, resampling on
134
+ * the way out.
135
+ *
136
+ * The resampling is here rather than in the SDK because these cores emit at a
137
+ * rate the SDK does not choose and cannot pin: 131072 Hz on Game Boy, and 32768
138
+ * or 65536 Hz on GBA depending on what the running game has written to
139
+ * SOUNDBIAS. A `rate` message retunes the read cursor mid-stream, so a game
140
+ * changing resolution is a ratio change and not a glitch.
141
+ *
142
+ * `consumed` is reported in *source* frames — that is the unit the SDK's pacing
143
+ * arithmetic works in, since that is what the core produces.
144
+ */
145
+ const WORKLET_SOURCE = `
146
+ class MgbaSink extends AudioWorkletProcessor {
147
+ constructor() {
148
+ super();
149
+ this.cap = 32768; // source frames
150
+ this.buf = new Float32Array(this.cap * 2);
151
+ this.pos = 0; // fractional read cursor, in source frames
152
+ this.w = 0; // source frames written
153
+ this.ratio = 1;
154
+ this.lastPost = 0;
155
+ this.port.onmessage = (e) => {
156
+ const msg = e.data;
157
+ if (msg && msg.rate) {
158
+ this.ratio = msg.rate / sampleRate;
159
+ return;
160
+ }
161
+ const s = msg; // Int16Array, interleaved stereo
162
+ const frames = s.length >> 1;
163
+ for (let i = 0; i < frames; i++) {
164
+ if (this.w - this.pos >= this.cap) break; // full: drop excess
165
+ const idx = (this.w % this.cap) * 2;
166
+ this.buf[idx] = s[i * 2] / 32768;
167
+ this.buf[idx + 1] = s[i * 2 + 1] / 32768;
168
+ this.w++;
169
+ }
170
+ };
171
+ }
172
+ process(inputs, outputs) {
173
+ const out = outputs[0];
174
+ const L = out[0];
175
+ const R = out[1] || out[0];
176
+ const n = L.length;
177
+ for (let i = 0; i < n; i++) {
178
+ // One source frame must be left past the cursor to interpolate against.
179
+ if (this.pos + 1 < this.w) {
180
+ const base = Math.floor(this.pos);
181
+ const frac = this.pos - base;
182
+ const a = (base % this.cap) * 2;
183
+ const b = ((base + 1) % this.cap) * 2;
184
+ L[i] = this.buf[a] + (this.buf[b] - this.buf[a]) * frac;
185
+ R[i] = this.buf[a + 1] + (this.buf[b + 1] - this.buf[a + 1]) * frac;
186
+ this.pos += this.ratio;
187
+ } else {
188
+ L[i] = 0;
189
+ R[i] = 0;
190
+ }
191
+ }
192
+ const consumed = Math.floor(this.pos);
193
+ if (consumed - this.lastPost >= 1024) {
194
+ this.port.postMessage(consumed);
195
+ this.lastPost = consumed;
196
+ }
197
+ return true;
198
+ }
199
+ }
200
+ registerProcessor('mgba-sink', MgbaSink);
201
+ `;
202
+
203
+ const scriptLoadCache = new Map<string, Promise<void>>();
204
+
205
+ function loadClassicScriptOnce(src: string): Promise<void> {
206
+ const cached = scriptLoadCache.get(src);
207
+ if (cached) return cached;
208
+
209
+ const p = new Promise<void>((resolve, reject) => {
210
+ const script = document.createElement('script');
211
+ script.src = src;
212
+ script.async = true;
213
+ script.onload = () => resolve();
214
+ script.onerror = () => reject(new Error(`mgba: failed to load script: ${src}`));
215
+ document.head.appendChild(script);
216
+ });
217
+
218
+ scriptLoadCache.set(src, p);
219
+ return p;
220
+ }
221
+
222
+ function toUint8(x: AssetData | undefined | unknown): Uint8Array | null {
223
+ if (x == null) return null;
224
+ if (typeof x === 'string') return new TextEncoder().encode(x);
225
+ if (x instanceof Uint8Array) return x;
226
+ if (x instanceof ArrayBuffer) return new Uint8Array(x);
227
+ if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
228
+ throw new TypeError('mgba: asset must be Uint8Array | ArrayBuffer | string');
229
+ }
230
+
231
+ function resolveCanvas(config: EngineConfig): HTMLCanvasElement {
232
+ const canvasEl = (config as { canvasEl?: HTMLCanvasElement }).canvasEl;
233
+ if (canvasEl) return canvasEl;
234
+ const attachTo = (config as { attachTo?: HTMLElement }).attachTo;
235
+ if (attachTo) {
236
+ const existing = attachTo.querySelector('canvas');
237
+ if (existing) return existing;
238
+ const created = document.createElement('canvas');
239
+ attachTo.appendChild(created);
240
+ return created;
241
+ }
242
+ throw new Error('mgba: config.canvasEl or config.attachTo is required');
243
+ }
244
+
245
+ async function opfsDir(
246
+ namespace: string,
247
+ create: boolean,
248
+ ): Promise<FileSystemDirectoryHandle | null> {
249
+ try {
250
+ const root = await navigator.storage.getDirectory();
251
+ const engineDir = await root.getDirectoryHandle('mgba', { create });
252
+ return await engineDir.getDirectoryHandle(namespace, { create });
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /** Copy bytes into the WASM heap; returns the pointer (caller frees). */
259
+ function heapAlloc(mod: MgbaModule, bytes: Uint8Array): number {
260
+ const ptr = mod._malloc(bytes.length);
261
+ mod.HEAPU8.set(bytes, ptr);
262
+ return ptr;
263
+ }
264
+
265
+ export type MgbaInstance = EngineInstance & {
266
+ /** Live handle on the emulator settings; writes apply to the running game. */
267
+ config: MgbaConfig;
268
+ /**
269
+ * The option rows for the demo shell's ESC menu, in the shape
270
+ * `@wasm-gaming/engine-specs` (>=0.2.5) renders. Snapshot at call time — read
271
+ * it when the menu opens, not once at boot.
272
+ */
273
+ escMenuGroups(): EscMenuGroup[];
274
+ /** Which core actually booted the ROM. */
275
+ system: 'gba' | 'gb';
276
+ };
277
+
278
+ export async function load(config: EngineConfig): Promise<MgbaInstance> {
279
+ const { assets, onEvent } = config;
280
+
281
+ const emit = (e: EngineEvent): void => {
282
+ try {
283
+ onEvent?.(e);
284
+ } catch {
285
+ // host callback must not break the engine runtime
286
+ }
287
+ };
288
+
289
+ const rawRom = toUint8(assets?.rom ?? assets?.data);
290
+ if (!rawRom) {
291
+ throw new Error('mgba: no ROM provided — pass assets.rom (a .gba/.gb/.gbc cartridge image)');
292
+ }
293
+ // Cartridge dumps circulate zipped far more often than not, and this build
294
+ // carries no libzip, so the archive is opened here instead.
295
+ const { bytes: romBytes } = await extractRom(rawRom, ['.gba', '.gb', '.gbc', '.sgb']);
296
+ const biosBytes = toUint8(assets?.bios);
297
+
298
+ const namespace = config.storageNamespace ?? 'default';
299
+ const settingsStore = createSettingsStore(namespace);
300
+ const requested = (config.options ?? {}) as Record<string, unknown>;
301
+
302
+ // This package's defaults, then the player's persisted menu tweaks, then
303
+ // whatever the host asked for explicitly — a caller-supplied option is a
304
+ // deliberate choice and outranks the last session.
305
+ const opts: Required<MgbaOptions> = { ...DEFAULT_MGBA_OPTIONS };
306
+ // The same object seen by key, which is what the live config writes through:
307
+ // the loop reads `opts` every frame, so a menu change is picked up with no
308
+ // further plumbing.
309
+ const state = opts as unknown as Record<string, MgbaOptionValue>;
310
+
311
+ const persisted = settingsStore.load();
312
+ for (const option of MGBA_ENGINE_OPTIONS) {
313
+ const stored = persisted[option.key];
314
+ if (stored !== undefined) state[option.key] = stored;
315
+
316
+ const asked = requested[option.key];
317
+ if (asked === undefined) continue;
318
+ const value = coerceOptionValue(option, asked);
319
+ if (value !== undefined) state[option.key] = value;
320
+ }
321
+ if (typeof requested.escMenu === 'boolean') opts.escMenu = requested.escMenu;
322
+
323
+ const canvas = resolveCanvas(config);
324
+ canvas.style.imageRendering = opts.renderFilter === 'pixelated' ? 'pixelated' : 'auto';
325
+ const ctx2d = canvas.getContext('2d');
326
+ if (!ctx2d) throw new Error('mgba: could not acquire a 2d canvas context');
327
+
328
+ // ---------------------------------------------------------------- audio
329
+ const audioCtx = new AudioContext();
330
+
331
+ const workletUrl = URL.createObjectURL(
332
+ new Blob([WORKLET_SOURCE], { type: 'application/javascript' }),
333
+ );
334
+ await audioCtx.audioWorklet.addModule(workletUrl);
335
+ URL.revokeObjectURL(workletUrl);
336
+
337
+ const sink = new AudioWorkletNode(audioCtx, 'mgba-sink', {
338
+ numberOfInputs: 0,
339
+ numberOfOutputs: 1,
340
+ outputChannelCount: [2],
341
+ });
342
+ const gain = audioCtx.createGain();
343
+ gain.gain.value = Math.max(0, Math.min(1, opts.volume));
344
+ sink.connect(gain).connect(audioCtx.destination);
345
+
346
+ let enqueuedFrames = 0;
347
+ let consumedFrames = 0;
348
+ sink.port.onmessage = (e: MessageEvent<number>) => {
349
+ consumedFrames = e.data;
350
+ };
351
+
352
+ // --------------------------------------------------------------- module
353
+ const jsUrl = config.jsUrl ?? new URL('./mgba.js', import.meta.url).href;
354
+ const wasmUrl = config.wasmUrl ?? new URL('./mgba.wasm', jsUrl).href;
355
+
356
+ await loadClassicScriptOnce(jsUrl);
357
+
358
+ const g = globalThis as { createMgbaModule?: MgbaModuleFactory };
359
+ if (typeof g.createMgbaModule !== 'function') {
360
+ throw new Error('mgba: unable to initialize runtime module from mgba.js');
361
+ }
362
+ const mod = await g.createMgbaModule({
363
+ locateFile(path: string): string {
364
+ if (path.endsWith('.wasm')) return wasmUrl;
365
+ return new URL(path, jsUrl).href;
366
+ },
367
+ });
368
+
369
+ // ----------------------------------------------------------------- boot
370
+ mod._mgbawasm_init();
371
+ mod._mgbawasm_set_log_level(MGBA_LOG_LEVEL_IDS[opts.logLevel]);
372
+
373
+ /** Copies a string into the heap as NUL-terminated UTF-8 (caller frees). */
374
+ const heapString = (value: string): number => {
375
+ const size = mod.lengthBytesUTF8(value) + 1;
376
+ const ptr = mod._malloc(size);
377
+ mod.stringToUTF8(value, ptr, size);
378
+ return ptr;
379
+ };
380
+
381
+ /**
382
+ * Boots the cartridge. Everything the core reads while mapping it — the
383
+ * platform, the Game Boy model, the BIOS, `skipBios` — is settled here,
384
+ * which is why the options that feed it are tagged "needs reset".
385
+ */
386
+ const bootCore = (): void => {
387
+ const romPtr = heapAlloc(mod, romBytes);
388
+ const biosPtr = biosBytes ? heapAlloc(mod, biosBytes) : 0;
389
+ const gbModel = MGBA_GB_MODEL_VALUES[opts.gbModel as MgbaGbModel];
390
+ const gbModelPtr = gbModel ? heapString(gbModel) : 0;
391
+ const ok = mod._mgbawasm_load(
392
+ romPtr,
393
+ romBytes.length,
394
+ biosPtr,
395
+ biosBytes?.length ?? 0,
396
+ MGBA_PLATFORM_IDS[opts.system as MgbaSystem],
397
+ gbModelPtr,
398
+ opts.skipBios ? 1 : 0,
399
+ );
400
+ mod._free(romPtr);
401
+ if (biosPtr) mod._free(biosPtr);
402
+ if (gbModelPtr) mod._free(gbModelPtr);
403
+ if (!ok) {
404
+ throw new Error('mgba: ROM load failed — is this a valid .gba/.gb/.gbc cartridge image?');
405
+ }
406
+ };
407
+
408
+ bootCore();
409
+
410
+ const platform = mod._mgbawasm_platform();
411
+ const system: 'gba' | 'gb' = platform === PLATFORM_GB ? 'gb' : 'gba';
412
+
413
+ // Without a BIOS image mGBA runs its high-level replacement, which has no
414
+ // boot animation to play — so the option is not merely ignored, it is
415
+ // meaningless, and the state is corrected rather than left lying.
416
+ const hasBios = mod._mgbawasm_has_bios() === 1;
417
+ if (!hasBios) opts.skipBios = true;
418
+
419
+ /** Maps a fresh cartridge, then re-pushes everything that is not boot-time. */
420
+ const rebootCore = (): void => {
421
+ bootCore();
422
+ applyLiveSettings();
423
+ framerate = mod._mgbawasm_framerate_micro() / 1e6 || 60;
424
+ };
425
+
426
+ /** Re-pushes every option the core keeps in its own config. */
427
+ const applyLiveSettings = (): void => {
428
+ mod._mgbawasm_set_log_level(MGBA_LOG_LEVEL_IDS[opts.logLevel]);
429
+ mod._mgbawasm_set_idle_optimization?.(IDLE_IDS[opts.idleOptimization] ?? 1);
430
+ mod._mgbawasm_set_allow_opposing_directions?.(opts.allowOpposingDirections ? 1 : 0);
431
+ };
432
+
433
+ applyLiveSettings();
434
+
435
+ let framerate = mod._mgbawasm_framerate_micro() / 1e6 || 60;
436
+
437
+ /**
438
+ * The rate the core is emitting at *now*, pushed to the worklet whenever it
439
+ * moves. A GBA game raising the SOUNDBIAS resolution doubles it mid-play, so
440
+ * this is re-read every tick rather than latched at boot.
441
+ */
442
+ let coreRate = 0;
443
+ const syncCoreRate = (): number => {
444
+ const rate = mod._mgbawasm_sample_rate();
445
+ if (rate > 0 && rate !== coreRate) {
446
+ coreRate = rate;
447
+ sink.port.postMessage({ rate });
448
+ }
449
+ return coreRate;
450
+ };
451
+ syncCoreRate();
452
+
453
+ // Scratch space the core drains audio into, sized for a comfortable multiple
454
+ // of a frame's worth of stereo samples.
455
+ const AUDIO_SCRATCH_FRAMES = 4096;
456
+ const audioPtr = mod._malloc(AUDIO_SCRATCH_FRAMES * 2 * 2);
457
+
458
+ // ---------------------------------------------------------- persistence
459
+ const persistEnabled = config.persist !== null && typeof navigator !== 'undefined';
460
+
461
+ /**
462
+ * mGBA hands out savedata by cloning it into a fresh buffer rather than
463
+ * exposing a live pointer, so a snapshot is taken and copied out before
464
+ * anything else can touch the heap.
465
+ */
466
+ const cloneSram = () => {
467
+ const size = mod._mgbawasm_sram_save();
468
+ if (!size) return null;
469
+ const ptr = mod._mgbawasm_sram_ptr();
470
+ if (!ptr) return null;
471
+ // Copied out of the heap rather than sliced from it, so the bytes are not
472
+ // sitting on the WASM memory that a growth could detach.
473
+ const bytes = new Uint8Array(size);
474
+ bytes.set(mod.HEAPU8.subarray(ptr, ptr + size));
475
+ return bytes;
476
+ };
477
+
478
+ const restoreSram = async (): Promise<void> => {
479
+ if (!persistEnabled) return;
480
+ const dir = await opfsDir(namespace, false);
481
+ if (!dir) return;
482
+ try {
483
+ const handle = await dir.getFileHandle('sram.bin');
484
+ const bytes = new Uint8Array(await (await handle.getFile()).arrayBuffer());
485
+ if (!bytes.length) return;
486
+ const ptr = heapAlloc(mod, bytes);
487
+ mod._mgbawasm_sram_load(ptr, bytes.length);
488
+ mod._free(ptr);
489
+ } catch {
490
+ // nothing saved yet for this namespace
491
+ }
492
+ };
493
+
494
+ const persistSram = async (): Promise<void> => {
495
+ if (!persistEnabled) return;
496
+ const bytes = cloneSram();
497
+ if (!bytes) return;
498
+ const dir = await opfsDir(namespace, true);
499
+ if (!dir) return;
500
+ try {
501
+ const handle = await dir.getFileHandle('sram.bin', { create: true });
502
+ const writable = await handle.createWritable();
503
+ await writable.write(bytes);
504
+ await writable.close();
505
+ } catch {
506
+ // persistence is best-effort
507
+ }
508
+ };
509
+
510
+ await restoreSram();
511
+
512
+ // ---------------------------------------------------------------- input
513
+ let keyMask = 0;
514
+ let padMask = 0;
515
+ let sentMask = -1;
516
+ let codeToBit = new Map<string, number>();
517
+
518
+ const pushInput = (): void => {
519
+ const mask = keyMask | padMask;
520
+ if (mask !== sentMask) {
521
+ sentMask = mask;
522
+ mod._mgbawasm_set_keys(mask);
523
+ }
524
+ };
525
+
526
+ const buildKeymap = (map: Record<string, string>): void => {
527
+ codeToBit = new Map();
528
+ for (const [action, code] of Object.entries(map)) {
529
+ const dot = action.indexOf('.');
530
+ if (!code) continue;
531
+ // The handheld has one controller, so a "p1." prefix is accepted but
532
+ // never required.
533
+ const control = dot < 0 ? action : action.slice(dot + 1);
534
+ const bit = KEY_BITS[control];
535
+ if (bit !== undefined) codeToBit.set(code, bit);
536
+ }
537
+ };
538
+ buildKeymap(DEFAULT_KEYMAP);
539
+
540
+ const applyKey = (code: string, down: boolean): boolean => {
541
+ const bit = codeToBit.get(code);
542
+ if (bit === undefined) return false;
543
+ const flag = 1 << bit;
544
+ keyMask = down ? keyMask | flag : keyMask & ~flag;
545
+ pushInput();
546
+ return true;
547
+ };
548
+
549
+ const onKeyDown = (e: KeyboardEvent): void => {
550
+ if (e.repeat) return;
551
+ if (applyKey(e.code, true)) e.preventDefault();
552
+ };
553
+ const onKeyUp = (e: KeyboardEvent): void => {
554
+ if (applyKey(e.code, false)) e.preventDefault();
555
+ };
556
+ window.addEventListener('keydown', onKeyDown);
557
+ window.addEventListener('keyup', onKeyUp);
558
+
559
+ /** Gamepad polling (standard mapping). */
560
+ const pollGamepads = (): void => {
561
+ if (!opts.gamepads || !navigator.getGamepads) return;
562
+ const pad = navigator.getGamepads()[0];
563
+ let mask = 0;
564
+ if (pad && pad.connected) {
565
+ const btn = (i: number): boolean => !!pad.buttons[i]?.pressed;
566
+ const ax = (i: number): number => pad.axes[i] ?? 0;
567
+ if (btn(0)) mask |= 1 << KEY_BITS.a;
568
+ if (btn(1)) mask |= 1 << KEY_BITS.b;
569
+ if (btn(4)) mask |= 1 << KEY_BITS.l;
570
+ if (btn(5)) mask |= 1 << KEY_BITS.r;
571
+ if (btn(8)) mask |= 1 << KEY_BITS.select;
572
+ if (btn(9)) mask |= 1 << KEY_BITS.start;
573
+ if (btn(12) || ax(1) < -0.4) mask |= 1 << KEY_BITS.up;
574
+ if (btn(13) || ax(1) > 0.4) mask |= 1 << KEY_BITS.down;
575
+ if (btn(14) || ax(0) < -0.4) mask |= 1 << KEY_BITS.left;
576
+ if (btn(15) || ax(0) > 0.4) mask |= 1 << KEY_BITS.right;
577
+ }
578
+ if (mask !== padMask) {
579
+ padMask = mask;
580
+ pushInput();
581
+ }
582
+ };
583
+
584
+ // Browsers may refuse to start audio without a user gesture; retry on the
585
+ // next interaction if the context comes up suspended.
586
+ const resumeAudio = (): void => {
587
+ if (audioCtx.state === 'suspended') void audioCtx.resume();
588
+ };
589
+ window.addEventListener('pointerdown', resumeAudio);
590
+ window.addEventListener('keydown', resumeAudio);
591
+
592
+ // --------------------------------------------------------------- render
593
+ // Geometry is read from the core every frame: it is 240x160 on GBA, 160x144
594
+ // on Game Boy, and 256x224 the moment a Super Game Boy border appears.
595
+ let imageData: ImageData | null = null;
596
+ let lastW = 0;
597
+ let lastH = 0;
598
+ // Previous frame, kept only while interframe blending is on.
599
+ let prevFrame: Uint8ClampedArray | null = null;
600
+
601
+ /**
602
+ * Publishes the presentation ratio two ways.
603
+ *
604
+ * `aspect-ratio` shapes the element. `--mgba-aspect` is the same number in a
605
+ * form arithmetic can use, so a stylesheet can size the canvas to fit its
606
+ * container without knowing which core is running:
607
+ *
608
+ * width: min(100%, calc(100% * var(--mgba-aspect)));
609
+ *
610
+ * Which is not a detail a host can hardcode here: it is 1.5 on GBA, 1.11 on
611
+ * Game Boy, 1.14 once a Super Game Boy border appears, and 1.33 when the
612
+ * player picks `aspect: '4:3'`.
613
+ */
614
+ const applyAspect = (): void => {
615
+ if (!lastW) return;
616
+ const fourThirds = opts.aspect === '4:3';
617
+ canvas.style.aspectRatio = fourThirds ? '4 / 3' : `${lastW} / ${lastH}`;
618
+ canvas.style.setProperty('--mgba-aspect', String(fourThirds ? 4 / 3 : lastW / lastH));
619
+ };
620
+
621
+ const renderFrame = (): void => {
622
+ const w = mod._mgbawasm_video_width();
623
+ const h = mod._mgbawasm_video_height();
624
+ if (w <= 0 || h <= 0) return;
625
+
626
+ if (w !== lastW || h !== lastH || !imageData) {
627
+ canvas.width = w;
628
+ canvas.height = h;
629
+ imageData = ctx2d.createImageData(w, h);
630
+ lastW = w;
631
+ lastH = h;
632
+ prevFrame = null;
633
+ applyAspect();
634
+ }
635
+
636
+ const ptr = mod._mgbawasm_video_ptr();
637
+ const src = mod.HEAPU8.subarray(ptr, ptr + w * h * 4);
638
+
639
+ if (opts.interframeBlending) {
640
+ // The unlit GBA/GB LCDs were slow enough that a sprite drawn every other
641
+ // frame read as half-transparent. Averaging consecutive frames restores
642
+ // that, and is what the effect is on every other frontend too.
643
+ const dst = imageData.data;
644
+ if (prevFrame && prevFrame.length === src.length) {
645
+ for (let i = 0; i < src.length; i++) {
646
+ dst[i] = (src[i] + prevFrame[i]) >> 1;
647
+ }
648
+ } else {
649
+ dst.set(src);
650
+ }
651
+ prevFrame = Uint8ClampedArray.from(src);
652
+ } else {
653
+ imageData.data.set(src);
654
+ prevFrame = null;
655
+ }
656
+
657
+ ctx2d.putImageData(imageData, 0, 0);
658
+ };
659
+
660
+ // ------------------------------------------------------------ main loop
661
+ // Audio-clocked pacing: keep ~90ms of audio queued ahead of the worklet's
662
+ // consumption. While the AudioContext is suspended (no consumption), fall
663
+ // back to wall-clock pacing so video still runs.
664
+ //
665
+ // Everything here counts in source frames, not output frames — the core's
666
+ // rate is the one that fixes how much work a frame is, and the worklet
667
+ // reports its consumption in the same unit for exactly that reason.
668
+ const TARGET_SECONDS = 0.09;
669
+ const MAX_FRAMES_PER_TICK = 5;
670
+
671
+ let rafId = 0;
672
+ let running = false;
673
+ let paused = false;
674
+ let wallClockFrames = 0;
675
+ let wallClockStart = 0;
676
+ let fpsCount = 0;
677
+ let fpsWindowStart = 0;
678
+ let persistTimer: ReturnType<typeof setInterval> | null = null;
679
+
680
+ // Audio-clocked pacing only works while the audio clock actually advances.
681
+ // A context can report `running` and still never render a quantum when there
682
+ // is no output device (headless browsers, VMs without a sound card), and
683
+ // then the buffer deficit never reappears and the picture freezes for good.
684
+ // Watch AudioContext.currentTime and treat a clock that has not moved for
685
+ // half a second as stalled, so pacing falls back to the wall clock.
686
+ const AUDIO_STALL_MS = 500;
687
+ let audioClockTime = -1;
688
+ let audioClockWall = 0;
689
+ let audioStalled = false;
690
+
691
+ const audioClockAdvancing = (now: number): boolean => {
692
+ const t = audioCtx.currentTime;
693
+ if (t !== audioClockTime) {
694
+ audioClockTime = t;
695
+ audioClockWall = now;
696
+ audioStalled = false;
697
+ } else if (now - audioClockWall > AUDIO_STALL_MS) {
698
+ audioStalled = true;
699
+ }
700
+ return !audioStalled;
701
+ };
702
+
703
+ const drainAudio = (): void => {
704
+ // Short reads are the norm: the sample count a frame produces wobbles
705
+ // around the nominal rate, so this drains whatever is actually there.
706
+ for (;;) {
707
+ const frames = mod._mgbawasm_read_audio(audioPtr, AUDIO_SCRATCH_FRAMES);
708
+ if (frames <= 0) return;
709
+ const start = audioPtr >> 1;
710
+ const chunk = mod.HEAP16.slice(start, start + frames * 2);
711
+ sink.port.postMessage(chunk, [chunk.buffer]);
712
+ enqueuedFrames += frames;
713
+ if (frames < AUDIO_SCRATCH_FRAMES) return;
714
+ }
715
+ };
716
+
717
+ const runFrames = (count: number): void => {
718
+ for (let i = 0; i < count; i++) {
719
+ mod._mgbawasm_run_frame();
720
+ drainAudio();
721
+ fpsCount++;
722
+ }
723
+ };
724
+
725
+ const tick = (now: number): void => {
726
+ if (!running || paused) return;
727
+ rafId = requestAnimationFrame(tick);
728
+
729
+ pollGamepads();
730
+
731
+ const rate = syncCoreRate();
732
+
733
+ let frames = 0;
734
+ if (audioCtx.state === 'running' && audioClockAdvancing(now)) {
735
+ const buffered = enqueuedFrames - consumedFrames;
736
+ const deficit = Math.round(rate * TARGET_SECONDS) - buffered;
737
+ const perEmulatedFrame = rate / framerate;
738
+ if (deficit > 0) frames = Math.ceil(deficit / perEmulatedFrame);
739
+ wallClockStart = 0;
740
+ } else {
741
+ // Wall-clock fallback (audio blocked or stalled): accumulate at the core
742
+ // framerate.
743
+ if (!wallClockStart) {
744
+ wallClockStart = now;
745
+ wallClockFrames = 0;
746
+ }
747
+ const due = Math.floor(((now - wallClockStart) / 1000) * framerate);
748
+ frames = due - wallClockFrames;
749
+ wallClockFrames = due;
750
+ }
751
+
752
+ frames = Math.max(0, Math.min(MAX_FRAMES_PER_TICK, frames));
753
+ if (frames > 0) {
754
+ runFrames(frames);
755
+ renderFrame();
756
+ }
757
+
758
+ if (!fpsWindowStart) fpsWindowStart = now;
759
+ if (now - fpsWindowStart >= 1000) {
760
+ emit({ type: 'frame', fps: (fpsCount * 1000) / (now - fpsWindowStart) });
761
+ fpsWindowStart = now;
762
+ fpsCount = 0;
763
+ }
764
+ };
765
+
766
+ const startLoop = (): void => {
767
+ if (rafId) cancelAnimationFrame(rafId);
768
+ wallClockStart = 0;
769
+ rafId = requestAnimationFrame(tick);
770
+ };
771
+
772
+ const setInput = (map: InputPreset | KeyMap): void => {
773
+ if (typeof map === 'string') {
774
+ buildKeymap(DEFAULT_KEYMAP);
775
+ } else {
776
+ buildKeymap({ ...DEFAULT_KEYMAP, ...map });
777
+ }
778
+ };
779
+
780
+ // -------------------------------------------------------- live settings
781
+ // What the cartridge was actually mapped with. A change to any of these only
782
+ // lands on the next boot, which is what reset() below does.
783
+ let bootedSystem: MgbaSystem = opts.system;
784
+ let bootedGbModel: MgbaGbModel = opts.gbModel;
785
+ let bootedSkipBios = opts.skipBios;
786
+
787
+ /**
788
+ * How each setting reaches the running emulator. A key left out here — or
789
+ * dropped because the loaded wasm predates its shim setter — is reported as
790
+ * unsupported and does not appear in the menu.
791
+ */
792
+ const appliers: MgbaAppliers = {
793
+ renderFilter: (value) => {
794
+ canvas.style.imageRendering = value === 'pixelated' ? 'pixelated' : 'auto';
795
+ },
796
+ aspect: () => applyAspect(),
797
+ interframeBlending: () => {
798
+ prevFrame = null;
799
+ },
800
+ volume: (value) => {
801
+ gain.gain.value = Math.max(0, Math.min(1, Number(value)));
802
+ },
803
+ gamepads: (value) => {
804
+ // Whatever a pad was holding when polling stopped would otherwise stay
805
+ // pressed forever.
806
+ if (!value) {
807
+ padMask = 0;
808
+ pushInput();
809
+ }
810
+ },
811
+ logLevel: (value) => mod._mgbawasm_set_log_level(MGBA_LOG_LEVEL_IDS[value as MgbaLogLevel]),
812
+ };
813
+
814
+ const setIdle = mod._mgbawasm_set_idle_optimization;
815
+ if (setIdle) {
816
+ appliers.idleOptimization = (value) => setIdle.call(mod, IDLE_IDS[String(value)] ?? 1);
817
+ }
818
+ const setOpposing = mod._mgbawasm_set_allow_opposing_directions;
819
+ if (setOpposing) {
820
+ appliers.allowOpposingDirections = (value) => setOpposing.call(mod, value ? 1 : 0);
821
+ }
822
+ // Recorded now, applied by reset().
823
+ appliers.system = () => {};
824
+ appliers.gbModel = () => {};
825
+ // Without a real BIOS there is no intro to skip, so the option is dropped
826
+ // from the menu entirely rather than offered as a lie.
827
+ if (hasBios) appliers.skipBios = () => {};
828
+
829
+ const engineConfig = bindConfig(state, appliers);
830
+
831
+ // The ESC menu lives in the host's demo shell as of engine-specs 0.2.5, so
832
+ // this package supplies the rows and applies the writes rather than drawing
833
+ // anything itself. Persisting on every write keeps the two in step without
834
+ // the shell having to know that persistence exists.
835
+ const engineWrite = engineConfig.write;
836
+ engineConfig.write = (key: string, value: MgbaOptionValue): boolean => {
837
+ const ok = engineWrite(key, value);
838
+ if (ok) settingsStore.save(engineConfig.values());
839
+ return ok;
840
+ };
841
+
842
+ const instance: MgbaInstance = {
843
+ config: engineConfig,
844
+ system,
845
+ escMenuGroups() {
846
+ return toEscMenuGroups(engineConfig.values());
847
+ },
848
+ start() {
849
+ if (running) return;
850
+ running = true;
851
+ paused = false;
852
+ resumeAudio();
853
+ startLoop();
854
+ if (persistEnabled) {
855
+ persistTimer = setInterval(() => void persistSram(), 15000);
856
+ }
857
+ emit({ type: 'ready' });
858
+ },
859
+ pause() {
860
+ if (!running || paused) return;
861
+ paused = true;
862
+ cancelAnimationFrame(rafId);
863
+ rafId = 0;
864
+ void audioCtx.suspend();
865
+ void persistSram();
866
+ },
867
+ resume() {
868
+ if (!running || !paused) return;
869
+ paused = false;
870
+ void audioCtx.resume();
871
+ startLoop();
872
+ },
873
+ reset() {
874
+ const needsReboot =
875
+ opts.system !== bootedSystem ||
876
+ opts.gbModel !== bootedGbModel ||
877
+ opts.skipBios !== bootedSkipBios;
878
+
879
+ if (!needsReboot) {
880
+ mod._mgbawasm_reset();
881
+ return;
882
+ }
883
+
884
+ // A reboot throws the cartridge away and maps a new one, so battery RAM
885
+ // is carried across by hand — on hardware it would have survived the
886
+ // power cycle.
887
+ const sram = cloneSram();
888
+ rebootCore();
889
+ bootedSystem = opts.system;
890
+ bootedGbModel = opts.gbModel;
891
+ bootedSkipBios = opts.skipBios;
892
+ if (sram) {
893
+ const ptr = heapAlloc(mod, sram);
894
+ mod._mgbawasm_sram_load(ptr, sram.length);
895
+ mod._free(ptr);
896
+ }
897
+ },
898
+ setInput,
899
+ async saveState(): Promise<Uint8Array> {
900
+ const size = mod._mgbawasm_state_size();
901
+ if (!size) throw new Error('mgba: failed to save state');
902
+ const ptr = mod._malloc(size);
903
+ const ok = mod._mgbawasm_state_save(ptr);
904
+ const bytes = ok ? mod.HEAPU8.slice(ptr, ptr + size) : null;
905
+ mod._free(ptr);
906
+ if (!bytes) throw new Error('mgba: failed to save state');
907
+ return bytes;
908
+ },
909
+ async loadState(data: Uint8Array): Promise<void> {
910
+ const size = mod._mgbawasm_state_size();
911
+ if (data.length !== size) {
912
+ throw new Error(
913
+ `mgba: savestate is ${data.length} bytes but this core expects ${size} — it belongs to a different game or a different build`,
914
+ );
915
+ }
916
+ const ptr = heapAlloc(mod, data);
917
+ const ok = mod._mgbawasm_state_load(ptr);
918
+ mod._free(ptr);
919
+ if (!ok) throw new Error('mgba: failed to load state');
920
+ },
921
+ async screenshot(): Promise<Blob> {
922
+ renderFrame();
923
+ return new Promise<Blob>((resolve, reject) => {
924
+ canvas.toBlob((blob) => {
925
+ if (blob) resolve(blob);
926
+ else reject(new Error('mgba: screenshot failed'));
927
+ }, 'image/png');
928
+ });
929
+ },
930
+ async purgeStorage(): Promise<{ data: boolean; settings: boolean }> {
931
+ let data = false;
932
+ try {
933
+ const root = await navigator.storage.getDirectory();
934
+ const engineDir = await root.getDirectoryHandle('mgba');
935
+ await engineDir.removeEntry(namespace, { recursive: true });
936
+ data = true;
937
+ } catch {
938
+ // nothing persisted for this namespace
939
+ }
940
+ settingsStore.clear();
941
+ return { data, settings: true };
942
+ },
943
+ destroy() {
944
+ running = false;
945
+ paused = false;
946
+ if (rafId) cancelAnimationFrame(rafId);
947
+ rafId = 0;
948
+ if (persistTimer) clearInterval(persistTimer);
949
+ persistTimer = null;
950
+ void persistSram();
951
+ window.removeEventListener('keydown', onKeyDown);
952
+ window.removeEventListener('keyup', onKeyUp);
953
+ window.removeEventListener('pointerdown', resumeAudio);
954
+ window.removeEventListener('keydown', resumeAudio);
955
+ sink.disconnect();
956
+ gain.disconnect();
957
+ void audioCtx.close();
958
+ mod._free(audioPtr);
959
+ mod._mgbawasm_unload();
960
+ emit({ type: 'exit' });
961
+ },
962
+ };
963
+
964
+ return instance;
965
+ }
966
+
967
+ export default { manifest, load };