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