@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.
- package/README.md +203 -0
- package/dist/manifest.json +146 -0
- package/dist/mgba/mgba.config.d.ts +41 -0
- package/dist/mgba/mgba.config.d.ts.map +1 -0
- package/dist/mgba/mgba.config.js +93 -0
- package/dist/mgba/mgba.config.js.map +1 -0
- package/dist/mgba/mgba.js +2 -0
- package/dist/mgba/mgba.manifest.d.ts +4 -0
- package/dist/mgba/mgba.manifest.d.ts.map +1 -0
- package/dist/mgba/mgba.manifest.js +38 -0
- package/dist/mgba/mgba.manifest.js.map +1 -0
- package/dist/mgba/mgba.options.d.ts +171 -0
- package/dist/mgba/mgba.options.d.ts.map +1 -0
- package/dist/mgba/mgba.options.js +273 -0
- package/dist/mgba/mgba.options.js.map +1 -0
- package/dist/mgba/mgba.sdk.d.ts +24 -0
- package/dist/mgba/mgba.sdk.d.ts.map +1 -0
- package/dist/mgba/mgba.sdk.js +846 -0
- package/dist/mgba/mgba.sdk.js.map +1 -0
- package/dist/mgba/mgba.wasm +0 -0
- package/dist/mgba/mgba.zip.d.ts +36 -0
- package/dist/mgba/mgba.zip.d.ts.map +1 -0
- package/dist/mgba/mgba.zip.js +105 -0
- package/dist/mgba/mgba.zip.js.map +1 -0
- package/package.json +53 -0
- package/src/mgba.config.ts +145 -0
- package/src/mgba.manifest.ts +43 -0
- package/src/mgba.options.ts +456 -0
- package/src/mgba.sdk.ts +967 -0
- package/src/mgba.zip.ts +140 -0
package/src/mgba.sdk.ts
ADDED
|
@@ -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 };
|