@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,456 @@
1
+ import type { JSONSchema } from '@wasm-gaming/engine-specs';
2
+
3
+ /**
4
+ * Which core boots the ROM. `auto` lets `mCoreFindVF()` sniff the image, which
5
+ * is right for every well-formed dump; the forced modes exist for headerless
6
+ * or mislabelled files.
7
+ */
8
+ export type MgbaSystem = 'auto' | 'gba' | 'gb';
9
+
10
+ /**
11
+ * Game Boy hardware model, for ROMs the GB core boots. `auto` follows the
12
+ * cartridge header (a CGB-aware cart gets CGB, everything else DMG).
13
+ * Maps to mGBA's `gb.model` config value.
14
+ */
15
+ export type MgbaGbModel = 'auto' | 'dmg' | 'sgb' | 'cgb' | 'agb';
16
+
17
+ /**
18
+ * GBA idle-loop handling (`idleOptimization` in mGBA's config).
19
+ * `remove` skips the busy-waits most games spin in, which is where nearly all
20
+ * of the emulator's speed comes from; `ignore` emulates them cycle for cycle.
21
+ */
22
+ export type MgbaIdleOptimization = 'ignore' | 'remove' | 'detect';
23
+
24
+ export type MgbaLogLevel = 'off' | 'error' | 'debug';
25
+
26
+ export interface MgbaOptions {
27
+ /** Which core boots the ROM: auto-detected, or forced GBA / Game Boy. */
28
+ system?: MgbaSystem;
29
+ /** Game Boy hardware model. Ignored when the GBA core is running. */
30
+ gbModel?: MgbaGbModel;
31
+ /**
32
+ * Idle-loop optimization for the GBA core. `remove` is mGBA's own default
33
+ * and what makes full speed reachable; `ignore` is the accurate-but-slow
34
+ * setting a handful of games need.
35
+ */
36
+ idleOptimization?: MgbaIdleOptimization;
37
+ /**
38
+ * Skip the BIOS intro. Without a real BIOS this is forced on — the HLE
39
+ * BIOS has no intro to run.
40
+ */
41
+ skipBios?: boolean;
42
+ /**
43
+ * Let the D-pad report left+right (or up+down) at once. Real hardware
44
+ * cannot, and a few games misbehave when it happens.
45
+ */
46
+ allowOpposingDirections?: boolean;
47
+ /** Canvas scaling filter: `pixelated` for crisp pixels, `smooth` for linear. */
48
+ renderFilter?: 'pixelated' | 'smooth';
49
+ /**
50
+ * Presented aspect ratio. `native` is square pixels — 3:2 on GBA, 10:9 on
51
+ * Game Boy — which is what the LCDs actually had; `4:3` fills a TV-shaped
52
+ * frame instead.
53
+ */
54
+ aspect?: 'native' | '4:3';
55
+ /**
56
+ * Blend each frame with the previous one, approximating the ghosting of the
57
+ * original unlit LCD. Some games (and most transparency effects done by
58
+ * flickering) were drawn expecting it.
59
+ */
60
+ interframeBlending?: boolean;
61
+ /** Master audio volume, 0.0–1.0. */
62
+ volume?: number;
63
+ /** Poll connected gamepads (standard mapping) each frame. */
64
+ gamepads?: boolean;
65
+ /** Core messages printed to the console. */
66
+ logLevel?: MgbaLogLevel;
67
+ /** Show the demo shell's in-game settings menu on Escape. Defaults to `true`. */
68
+ escMenu?: boolean;
69
+ }
70
+
71
+ export const DEFAULT_MGBA_OPTIONS: Required<MgbaOptions> = {
72
+ system: 'auto',
73
+ gbModel: 'auto',
74
+ idleOptimization: 'remove',
75
+ skipBios: true,
76
+ allowOpposingDirections: false,
77
+ renderFilter: 'pixelated',
78
+ aspect: 'native',
79
+ interframeBlending: false,
80
+ volume: 1.0,
81
+ gamepads: true,
82
+ logLevel: 'error',
83
+ escMenu: true,
84
+ };
85
+
86
+ /** Platform ids consumed by `mgbawasm_setup()` (mPLATFORM_* in core/core.h). */
87
+ export const MGBA_PLATFORM_IDS: Record<MgbaSystem, number> = {
88
+ auto: -1,
89
+ gba: 0,
90
+ gb: 1,
91
+ };
92
+
93
+ /** Values mGBA's `gb.model` config key takes; `auto` means "leave unset". */
94
+ export const MGBA_GB_MODEL_VALUES: Record<MgbaGbModel, string | null> = {
95
+ auto: null,
96
+ dmg: 'DMG',
97
+ sgb: 'SGB',
98
+ cgb: 'CGB',
99
+ agb: 'AGB',
100
+ };
101
+
102
+ export const MGBA_LOG_LEVEL_IDS: Record<MgbaLogLevel, number> = {
103
+ off: 0,
104
+ error: 1,
105
+ debug: 2,
106
+ };
107
+
108
+ // --------------------------------------------------------------- catalog
109
+
110
+ /**
111
+ * Settings the SDK can change on a running game, described once here.
112
+ *
113
+ * mGBA keeps its configuration in an `mCoreConfig` the core re-reads when the
114
+ * frontend calls `reloadConfigOption()`, so most of these reach the emulation
115
+ * without a restart; the ones that are consumed while the cartridge is being
116
+ * mapped (the system, the Game Boy model, the BIOS) are tagged `requiresReset`.
117
+ * The rest — canvas filtering, aspect, blending, volume — never reach the core
118
+ * at all and are applied by the SDK.
119
+ *
120
+ * `DEFAULT_MGBA_OPTIONS`, the manifest's options schema and the demo shell's
121
+ * ESC menu are all derived from this catalog, so adding a row here is enough to
122
+ * expose a new setting.
123
+ */
124
+ export type MgbaOptionKey = Exclude<keyof MgbaOptions, 'escMenu'>;
125
+
126
+ export type MgbaOptionValue = boolean | string | number;
127
+
128
+ interface OptionSpecBase {
129
+ /** Option key, as used in `EngineConfig.options` and the manifest schema. */
130
+ key: MgbaOptionKey;
131
+ label: string;
132
+ description: string;
133
+ /**
134
+ * Takes effect on the next power-on rather than immediately — the ESC menu
135
+ * tags these, and the SDK applies them from `reset()`.
136
+ */
137
+ requiresReset?: boolean;
138
+ }
139
+
140
+ /** One selectable value, as offered by the menu. */
141
+ export interface MgbaChoice<T> {
142
+ value: T;
143
+ label: string;
144
+ }
145
+
146
+ export type MgbaOptionSpec = OptionSpecBase &
147
+ (
148
+ | { type: 'boolean'; default: boolean }
149
+ | { type: 'enum'; default: string; values: MgbaChoice<string>[] }
150
+ | {
151
+ type: 'number';
152
+ default: number;
153
+ /** Values the menu cycles through; the schema may still take a range. */
154
+ values: MgbaChoice<number>[];
155
+ integer?: boolean;
156
+ /**
157
+ * When set, the schema advertises this range instead of the menu's
158
+ * choices, so hosts can pass values the menu does not offer.
159
+ */
160
+ range?: { minimum: number; maximum: number };
161
+ }
162
+ );
163
+
164
+ export interface MgbaOptionGroup {
165
+ id: string;
166
+ label: string;
167
+ options: MgbaOptionSpec[];
168
+ }
169
+
170
+ export const MGBA_OPTION_GROUPS: MgbaOptionGroup[] = [
171
+ {
172
+ id: 'video',
173
+ label: 'Video',
174
+ options: [
175
+ {
176
+ key: 'renderFilter',
177
+ label: 'Image filtering',
178
+ description:
179
+ 'Scaling filter applied when the picture is stretched to the canvas. Pixelated keeps pixel art crisp; smooth softens it.',
180
+ type: 'enum',
181
+ default: DEFAULT_MGBA_OPTIONS.renderFilter,
182
+ values: [
183
+ { value: 'pixelated', label: 'Pixelated' },
184
+ { value: 'smooth', label: 'Smooth' },
185
+ ],
186
+ },
187
+ {
188
+ key: 'aspect',
189
+ label: 'Aspect ratio',
190
+ description:
191
+ 'Native is square pixels, as the handheld LCDs had them: 3:2 on GBA, 10:9 on Game Boy. 4:3 stretches the picture into a TV-shaped frame.',
192
+ type: 'enum',
193
+ default: DEFAULT_MGBA_OPTIONS.aspect,
194
+ values: [
195
+ { value: 'native', label: 'Native' },
196
+ { value: '4:3', label: '4:3' },
197
+ ],
198
+ },
199
+ {
200
+ key: 'interframeBlending',
201
+ label: 'Interframe blending',
202
+ description:
203
+ 'Blend each frame with the previous one, approximating the ghosting of the original unlit LCD. Restores transparency effects that games drew by flickering sprites every other frame.',
204
+ type: 'boolean',
205
+ default: DEFAULT_MGBA_OPTIONS.interframeBlending,
206
+ },
207
+ ],
208
+ },
209
+ {
210
+ id: 'audio',
211
+ label: 'Audio',
212
+ options: [
213
+ {
214
+ key: 'volume',
215
+ label: 'Volume',
216
+ description: 'Master audio volume.',
217
+ type: 'number',
218
+ default: DEFAULT_MGBA_OPTIONS.volume,
219
+ values: [
220
+ { value: 0, label: 'Mute' },
221
+ { value: 0.25, label: '25%' },
222
+ { value: 0.5, label: '50%' },
223
+ { value: 0.75, label: '75%' },
224
+ { value: 1, label: '100%' },
225
+ ],
226
+ range: { minimum: 0, maximum: 1 },
227
+ },
228
+ ],
229
+ },
230
+ {
231
+ id: 'emulation',
232
+ label: 'Emulation',
233
+ options: [
234
+ {
235
+ key: 'system',
236
+ label: 'System',
237
+ description:
238
+ 'Which core boots the ROM. Auto sniffs the image, which is right for every well-formed dump; force it for headerless or mislabelled files.',
239
+ type: 'enum',
240
+ default: DEFAULT_MGBA_OPTIONS.system,
241
+ requiresReset: true,
242
+ values: [
243
+ { value: 'auto', label: 'Auto' },
244
+ { value: 'gba', label: 'GBA' },
245
+ { value: 'gb', label: 'Game Boy' },
246
+ ],
247
+ },
248
+ {
249
+ key: 'gbModel',
250
+ label: 'Game Boy model',
251
+ description:
252
+ 'Hardware the Game Boy core emulates. Auto follows the cartridge header. Ignored when a GBA ROM is loaded.',
253
+ type: 'enum',
254
+ default: DEFAULT_MGBA_OPTIONS.gbModel,
255
+ requiresReset: true,
256
+ values: [
257
+ { value: 'auto', label: 'Auto' },
258
+ { value: 'dmg', label: 'DMG' },
259
+ { value: 'sgb', label: 'Super GB' },
260
+ { value: 'cgb', label: 'Color' },
261
+ { value: 'agb', label: 'GBA' },
262
+ ],
263
+ },
264
+ {
265
+ key: 'idleOptimization',
266
+ label: 'Idle loops',
267
+ description:
268
+ 'How the GBA core treats the busy-wait loops games spin in. Remove skips them and is where nearly all of the speed comes from; ignore emulates them cycle for cycle.',
269
+ type: 'enum',
270
+ default: DEFAULT_MGBA_OPTIONS.idleOptimization,
271
+ values: [
272
+ { value: 'remove', label: 'Remove' },
273
+ { value: 'detect', label: 'Detect' },
274
+ { value: 'ignore', label: 'Ignore' },
275
+ ],
276
+ },
277
+ {
278
+ key: 'skipBios',
279
+ label: 'Skip BIOS intro',
280
+ description:
281
+ 'Jump straight into the game instead of playing the boot animation. Forced on when no BIOS image was supplied, since the built-in HLE BIOS has no intro.',
282
+ type: 'boolean',
283
+ default: DEFAULT_MGBA_OPTIONS.skipBios,
284
+ requiresReset: true,
285
+ },
286
+ ],
287
+ },
288
+ {
289
+ id: 'controllers',
290
+ label: 'Controllers',
291
+ options: [
292
+ {
293
+ key: 'allowOpposingDirections',
294
+ label: 'Opposing directions',
295
+ description:
296
+ 'Let the D-pad report left+right (or up+down) at once. Real hardware cannot, and a few games misbehave when it happens.',
297
+ type: 'boolean',
298
+ default: DEFAULT_MGBA_OPTIONS.allowOpposingDirections,
299
+ },
300
+ {
301
+ key: 'gamepads',
302
+ label: 'Gamepads',
303
+ description: 'Poll connected gamepads (standard mapping) each frame.',
304
+ type: 'boolean',
305
+ default: DEFAULT_MGBA_OPTIONS.gamepads,
306
+ },
307
+ ],
308
+ },
309
+ {
310
+ id: 'debug',
311
+ label: 'Debug',
312
+ options: [
313
+ {
314
+ key: 'logLevel',
315
+ label: 'Core logging',
316
+ description:
317
+ 'Core messages printed to the browser console: errors and warnings, or also its informational ones.',
318
+ type: 'enum',
319
+ default: DEFAULT_MGBA_OPTIONS.logLevel,
320
+ values: [
321
+ { value: 'off', label: 'Off' },
322
+ { value: 'error', label: 'Errors' },
323
+ { value: 'debug', label: 'Debug' },
324
+ ],
325
+ },
326
+ ],
327
+ },
328
+ ];
329
+
330
+ /** Flat view of every runtime-tweakable option across all groups. */
331
+ export const MGBA_ENGINE_OPTIONS: MgbaOptionSpec[] = MGBA_OPTION_GROUPS.flatMap(
332
+ (group) => group.options,
333
+ );
334
+
335
+ const OPTION_BY_KEY = new Map<string, MgbaOptionSpec>(
336
+ MGBA_ENGINE_OPTIONS.map((option) => [option.key, option]),
337
+ );
338
+
339
+ export function mgbaOption(key: string): MgbaOptionSpec | undefined {
340
+ return OPTION_BY_KEY.get(key);
341
+ }
342
+
343
+ /**
344
+ * Coerces a host- or storage-supplied value to what the option accepts,
345
+ * returning `undefined` when it is not a value the option can take. Numbers
346
+ * outside a `range` are clamped rather than rejected; enums are exact.
347
+ */
348
+ export function coerceOptionValue(
349
+ option: MgbaOptionSpec,
350
+ value: unknown,
351
+ ): MgbaOptionValue | undefined {
352
+ if (option.type === 'boolean') {
353
+ return typeof value === 'boolean' ? value : undefined;
354
+ }
355
+
356
+ if (option.type === 'enum') {
357
+ const next = String(value);
358
+ return option.values.some((choice) => choice.value === next) ? next : undefined;
359
+ }
360
+
361
+ const next = typeof value === 'number' ? value : Number(value);
362
+ if (!Number.isFinite(next)) return undefined;
363
+ if (option.range) {
364
+ return Math.min(option.range.maximum, Math.max(option.range.minimum, next));
365
+ }
366
+ return option.values.some((choice) => choice.value === next) ? next : undefined;
367
+ }
368
+
369
+ // ------------------------------------------------- engine-specs ESC menu
370
+
371
+ /** One row as the `esc-menu` component of the demo shell renders it. */
372
+ export interface EscMenuOption {
373
+ key: string;
374
+ label: string;
375
+ description: string;
376
+ type: 'boolean' | 'enum';
377
+ value: MgbaOptionValue;
378
+ values?: MgbaChoice<MgbaOptionValue>[];
379
+ requiresReset?: boolean;
380
+ }
381
+
382
+ export interface EscMenuGroup {
383
+ id: string;
384
+ label: string;
385
+ options: EscMenuOption[];
386
+ }
387
+
388
+ /**
389
+ * Projects the catalog onto the shape `@wasm-gaming/engine-specs` (>=0.2.5)
390
+ * feeds its `esc-menu` component.
391
+ *
392
+ * The menu lives in the demo shell now, not in this package: the shell renders
393
+ * the rows and emits `option-change`, and the host writes the value back
394
+ * through `engine.config`. Since the component only draws chips for `boolean`
395
+ * and `enum`, numeric options are handed over as an enum of their menu choices
396
+ * — the numbers survive, because the chips compare values with `===`.
397
+ *
398
+ * Pass the running engine's `config.values()` so the menu opens on what the
399
+ * emulator is actually set to rather than on this package's defaults.
400
+ */
401
+ export function toEscMenuGroups(
402
+ values: Record<string, MgbaOptionValue> = {},
403
+ ): EscMenuGroup[] {
404
+ return MGBA_OPTION_GROUPS.map((group) => ({
405
+ id: group.id,
406
+ label: group.label,
407
+ options: group.options.map((option): EscMenuOption => ({
408
+ key: option.key,
409
+ label: option.label,
410
+ description: option.description,
411
+ type: option.type === 'boolean' ? 'boolean' : 'enum',
412
+ value: values[option.key] ?? option.default,
413
+ ...(option.type === 'boolean' ? {} : { values: option.values }),
414
+ ...(option.requiresReset ? { requiresReset: true } : {}),
415
+ })),
416
+ }));
417
+ }
418
+
419
+ // -------------------------------------------------------------- schema
420
+
421
+ function schemaForOption(option: MgbaOptionSpec): JSONSchema {
422
+ if (option.type === 'boolean') {
423
+ return { type: 'boolean', default: option.default, description: option.description };
424
+ }
425
+ if (option.type === 'enum') {
426
+ return {
427
+ type: 'string',
428
+ enum: option.values.map((choice) => choice.value),
429
+ default: option.default,
430
+ description: option.description,
431
+ };
432
+ }
433
+ return {
434
+ type: option.integer ? 'integer' : 'number',
435
+ default: option.default,
436
+ ...(option.range
437
+ ? { minimum: option.range.minimum, maximum: option.range.maximum }
438
+ : { enum: option.values.map((choice) => choice.value) }),
439
+ description: option.description,
440
+ };
441
+ }
442
+
443
+ export const MGBA_OPTIONS_SCHEMA: JSONSchema = {
444
+ type: 'object',
445
+ additionalProperties: false,
446
+ properties: {
447
+ ...Object.fromEntries(
448
+ MGBA_ENGINE_OPTIONS.map((option) => [option.key, schemaForOption(option)]),
449
+ ),
450
+ escMenu: {
451
+ type: 'boolean',
452
+ default: true,
453
+ description: "Show the demo shell's in-game settings menu when the player presses Escape.",
454
+ },
455
+ },
456
+ };