@wasm-gaming/fbneo-wasm 0.1.2 → 0.2.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.
Files changed (54) hide show
  1. package/README.md +103 -69
  2. package/dist/fbneo/app/app.css +112 -0
  3. package/dist/fbneo/app/app.js +646 -0
  4. package/dist/fbneo/engine-specs.js +646 -0
  5. package/dist/fbneo/fbneo.js +2 -0
  6. package/dist/fbneo/fbneo.options.d.ts +66 -19
  7. package/dist/fbneo/fbneo.options.d.ts.map +1 -1
  8. package/dist/fbneo/fbneo.options.js +39 -44
  9. package/dist/fbneo/fbneo.options.js.map +1 -1
  10. package/dist/fbneo/fbneo.sdk.d.ts +137 -8
  11. package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
  12. package/dist/fbneo/fbneo.sdk.js +588 -184
  13. package/dist/fbneo/fbneo.sdk.js.map +1 -1
  14. package/dist/fbneo/fbneo.wasm +4 -0
  15. package/package.json +10 -15
  16. package/src/app/app.css +112 -0
  17. package/src/app/app.js +646 -0
  18. package/src/fbneo.options.ts +107 -63
  19. package/src/fbneo.sdk.ts +644 -212
  20. package/dist/fbneo/fbneo.manifest.d.ts +0 -4
  21. package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
  22. package/dist/fbneo/fbneo.manifest.js +0 -33
  23. package/dist/fbneo/fbneo.manifest.js.map +0 -1
  24. package/dist/manifest.json +0 -91
  25. package/src/fbneo.manifest.ts +0 -38
  26. /package/{data → dist/fbneo}/romsets/atari.json +0 -0
  27. /package/{data → dist/fbneo}/romsets/capcom.json +0 -0
  28. /package/{data → dist/fbneo}/romsets/cave.json +0 -0
  29. /package/{data → dist/fbneo}/romsets/channelf.json +0 -0
  30. /package/{data → dist/fbneo}/romsets/coleco.json +0 -0
  31. /package/{data → dist/fbneo}/romsets/cps3.json +0 -0
  32. /package/{data → dist/fbneo}/romsets/dataeast.json +0 -0
  33. /package/{data → dist/fbneo}/romsets/galaxian.json +0 -0
  34. /package/{data → dist/fbneo}/romsets/index.json +0 -0
  35. /package/{data → dist/fbneo}/romsets/irem.json +0 -0
  36. /package/{data → dist/fbneo}/romsets/konami.json +0 -0
  37. /package/{data → dist/fbneo}/romsets/megadrive.json +0 -0
  38. /package/{data → dist/fbneo}/romsets/midway.json +0 -0
  39. /package/{data → dist/fbneo}/romsets/msx.json +0 -0
  40. /package/{data → dist/fbneo}/romsets/neogeo.json +0 -0
  41. /package/{data → dist/fbneo}/romsets/nes.json +0 -0
  42. /package/{data → dist/fbneo}/romsets/pce.json +0 -0
  43. /package/{data → dist/fbneo}/romsets/pgm.json +0 -0
  44. /package/{data → dist/fbneo}/romsets/pgm2.json +0 -0
  45. /package/{data → dist/fbneo}/romsets/pre90s.json +0 -0
  46. /package/{data → dist/fbneo}/romsets/psikyo.json +0 -0
  47. /package/{data → dist/fbneo}/romsets/pst90s.json +0 -0
  48. /package/{data → dist/fbneo}/romsets/sega.json +0 -0
  49. /package/{data → dist/fbneo}/romsets/sg1000.json +0 -0
  50. /package/{data → dist/fbneo}/romsets/sms.json +0 -0
  51. /package/{data → dist/fbneo}/romsets/snes.json +0 -0
  52. /package/{data → dist/fbneo}/romsets/spectrum.json +0 -0
  53. /package/{data → dist/fbneo}/romsets/taito.json +0 -0
  54. /package/{data → dist/fbneo}/romsets/toaplan.json +0 -0
@@ -1,199 +1,603 @@
1
- import { manifest } from './fbneo.manifest.js';
2
- import { DEFAULT_FBNEO_OPTIONS } from './fbneo.options.js';
3
- export { manifest };
4
- const scriptLoadCache = new Map();
5
- function loadClassicScriptOnce(src) {
6
- const cached = scriptLoadCache.get(src);
7
- if (cached)
8
- return cached;
9
- const p = new Promise((resolve, reject) => {
10
- const script = document.createElement('script');
11
- script.src = src;
12
- script.async = true;
13
- script.onload = () => resolve();
14
- script.onerror = () => reject(new Error(`fbneo: failed to load script: ${src}`));
15
- document.head.appendChild(script);
16
- });
17
- scriptLoadCache.set(src, p);
18
- return p;
1
+ /**
2
+ * The engine package's entry point: an `EngineSDK` for FBNeo (FinalBurn Neo), the
3
+ * arcade emulator.
4
+ *
5
+ * ```js
6
+ * import FbneoSDK from '@wasm-gaming/fbneo-wasm';
7
+ *
8
+ * const play = await new FbneoSDK()
9
+ * .options({ driver: 'mslug' }) // or romFileName: 'mslug.zip'
10
+ * .assets({ rom: () => readZip(), bios: () => readBios() })
11
+ * .storage({ namespace: 'mslug' }) // → OPFS fbneo/mslug/
12
+ * .mount(document.querySelector('#stage'))
13
+ * .start();
14
+ *
15
+ * play.pause('menu'); play.resume('menu');
16
+ * await play.restart(); // soft reset
17
+ * await play.destroy(); // saves flushed, loop stopped
18
+ * ```
19
+ *
20
+ * Everything a host drives is the contract's (`@wasm-gaming/engine-specs`); what is
21
+ * this engine's own: {@link FbneoConfig} and friends, owner-aware
22
+ * {@link FbneoPlay.pause} / {@link FbneoPlay.resume}, {@link FbneoPlay.states},
23
+ * {@link FbneoPlay.storagePath} and the `saves` event.
24
+ *
25
+ * Saves: the build points FBNeo's NVRAM/EEPROM and hiscore paths at MEMFS `/save`.
26
+ * The SDK seeds it from OPFS `fbneo/<namespace>/` before boot and mirrors it back
27
+ * after asking FBNeo to write (`web_persist`): every 10 s, on pause, when the page is
28
+ * hidden and on destroy. Upstream only wrote at exit, which a web page never reaches.
29
+ */
30
+ import { EnginePlayBase, EngineSDKBase, EventEmitter, } from './engine-specs.js';
31
+ import { FBNEO_SPEC, FBNEO_VIDEO, } from './fbneo.options.js';
32
+ export { FBNEO_SPEC, FBNEO_VIDEO } from './fbneo.options.js';
33
+ const ENGINE_ID = 'fbneo';
34
+ const DEFAULT_STORAGE_NAMESPACE = 'default';
35
+ const SAVE_DIR = '/save';
36
+ /**
37
+ * FBNeo's ROM search paths (src/burner/sdl/drv.cpp): the absolute one is always
38
+ * searched; `/roms` covers the cwd-relative `roms/` with the cwd at `/`.
39
+ */
40
+ const ROM_DIRS = ['/usr/local/share/roms', '/roms'];
41
+ const SAVE_INTERVAL_MS = 10_000;
42
+ /** How long a ROM set may take to load before start() gives up. */
43
+ const LOAD_TIMEOUT_MS = 30_000;
44
+ /**
45
+ * Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
46
+ * root. Identical to the other engines': segments cleaned, `.` and `..` dropped,
47
+ * anything outside `[A-Za-z0-9._-]` → `_`, nothing usable → `default`; a leading
48
+ * `<engineId>/` is stripped once.
49
+ */
50
+ export function storagePath(engineId, namespace) {
51
+ const segments = typeof namespace === 'string'
52
+ ? namespace
53
+ .split('/')
54
+ .map((segment) => segment.trim())
55
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
56
+ .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
57
+ : [];
58
+ const prefixed = segments.length > 1 && segments[0] === engineId;
59
+ if (prefixed)
60
+ segments.shift();
61
+ const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
62
+ return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
19
63
  }
20
- function toUint8(x) {
21
- if (x == null)
64
+ /**
65
+ * The FBNeo driver for a session: `driver` when given, else the ROM file name's stem
66
+ * (`mslug.zip`, `C:\roms\mslug.7z` → `mslug`). Empty when neither says.
67
+ */
68
+ export function driverName(options) {
69
+ const explicit = options.driver?.trim();
70
+ if (explicit)
71
+ return explicit;
72
+ const base = (options.romFileName ?? '').replace(/^.*[\\/]/, '');
73
+ return base.replace(/\.(zip|7z)$/i, '').trim();
74
+ }
75
+ async function opfsDir(path, create) {
76
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory)
22
77
  return null;
23
- if (typeof x === 'string')
24
- return new TextEncoder().encode(x);
25
- if (x instanceof Uint8Array)
26
- return x;
27
- if (x instanceof ArrayBuffer)
28
- return new Uint8Array(x);
29
- if (ArrayBuffer.isView(x))
30
- return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
31
- throw new TypeError('fbneo: asset must be Uint8Array | ArrayBuffer | string');
78
+ try {
79
+ let dir = await navigator.storage.getDirectory();
80
+ for (const segment of path.split('/').filter(Boolean)) {
81
+ dir = await dir.getDirectoryHandle(segment, { create });
82
+ }
83
+ return dir;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
32
88
  }
33
- /** Strip a directory and extension from a zip filename to get the FBNeo driver name. */
34
- function driverFromFileName(fileName) {
35
- const base = fileName.replace(/^.*[\\/]/, '');
36
- return base.replace(/\.(zip|7z)$/i, '');
89
+ async function resolveAsset(asset) {
90
+ const value = typeof asset === 'function' ? await asset() : asset;
91
+ if (value == null)
92
+ return null;
93
+ if (value instanceof Uint8Array)
94
+ return value;
95
+ if (value instanceof ArrayBuffer)
96
+ return new Uint8Array(value);
97
+ throw new TypeError('fbneo: an asset must be a Uint8Array, an ArrayBuffer or a thunk returning one');
37
98
  }
38
- /**
39
- * Resolve the render canvas from the contract's `EngineConfig`.
40
- * The contract is a union: hosts pass either `canvasEl` (a render target) or
41
- * `attachTo` (a container the SDK mounts its own canvas into). Legacy `canvas`
42
- * is still accepted as a defensive fallback for hosts built against 0.1.x.
43
- */
44
- function resolveCanvas(config) {
45
- const c = config.canvasEl ??
46
- config.canvas;
47
- if (c)
48
- return c;
49
- const attachTo = config.attachTo;
50
- if (attachTo) {
51
- const existing = attachTo.querySelector('canvas');
52
- if (existing)
53
- return existing;
54
- const created = document.createElement('canvas');
55
- attachTo.appendChild(created);
56
- return created;
57
- }
58
- throw new Error('fbneo: config.canvasEl or config.attachTo is required');
99
+ function sameBytes(a, b) {
100
+ if (!a || a.length !== b.length)
101
+ return false;
102
+ for (let i = 0; i < a.length; i++)
103
+ if (a[i] !== b[i])
104
+ return false;
105
+ return true;
59
106
  }
107
+ /** The player's save-state slots: 0-3. */
108
+ export const STATE_SLOTS = 4;
60
109
  /**
61
- * Ensure the canvas used by FBNeo has `id="canvas"`.
62
- * Emscripten SDL2 queries `document.querySelector('#canvas')` to locate its render target.
110
+ * The *unsaved* suspend point: written when the player leaves, offered when they come
111
+ * back, and saved into a numbered slot only when they ask.
63
112
  */
64
- function prepareCanvas(canvas, renderFilter) {
65
- if (canvas.id !== 'canvas')
113
+ export const AUTO_STATE_SLOT = 9;
114
+ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
115
+ const mtimeOf = (value) => value instanceof Date ? value.getTime() : typeof value === 'number' ? value : 0;
116
+ /** The live engine: one booted FBNeo and its session. */
117
+ export class FbneoPlay extends EnginePlayBase {
118
+ #module = null;
119
+ #destroyed = false;
120
+ #pauseOwner = null;
121
+ #driver = '';
122
+ #canvas = null;
123
+ #ownsCanvas = false;
124
+ #box = null;
125
+ #boxObserver = null;
126
+ #canvasObserver = null;
127
+ #namespace = DEFAULT_STORAGE_NAMESPACE;
128
+ #storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
129
+ #persist = true;
130
+ #saveTimer = null;
131
+ #lastSaved = new Map();
132
+ #flushing = null;
133
+ #onHidden = () => {
134
+ if (document.visibilityState === 'hidden')
135
+ void this.flushSaves();
136
+ };
137
+ constructor(init, events) {
138
+ super(FBNEO_SPEC, init, events);
139
+ }
140
+ /** The normalised namespace this session uses. */
141
+ get namespace() {
142
+ return this.#namespace;
143
+ }
144
+ /** Where this session's saves live, relative to the OPFS root: `fbneo/<namespace>`. */
145
+ get storagePath() {
146
+ return this.#storagePath;
147
+ }
148
+ /** The driver (ROM-set short name) this session booted. */
149
+ get driver() {
150
+ return this.#driver;
151
+ }
152
+ /** Who holds the pause, or null while running. */
153
+ get pausedBy() {
154
+ return this.#pauseOwner;
155
+ }
156
+ /**
157
+ * The current frame as a PNG (also while paused: the picture is kept). Null when
158
+ * there is nothing to read.
159
+ */
160
+ async snapshot() {
161
+ const canvas = this.#canvas;
162
+ if (!canvas || !this.#module)
163
+ return null;
164
+ await nextFrame();
165
+ const copy = document.createElement('canvas');
166
+ copy.width = canvas.width || FBNEO_VIDEO.width;
167
+ copy.height = canvas.height || FBNEO_VIDEO.height;
168
+ copy.getContext('2d')?.drawImage(canvas, 0, 0, copy.width, copy.height);
169
+ return new Promise((resolve) => copy.toBlob((blob) => resolve(blob), 'image/png'));
170
+ }
171
+ /** Save states — see {@link FbneoStates}. */
172
+ get states() {
173
+ const Module = this.#requireModule();
174
+ const { FS } = Module;
175
+ const file = (name) => `${SAVE_DIR}/${name}`;
176
+ const exists = (name) => {
177
+ try {
178
+ FS.stat(file(name));
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ };
185
+ const stateName = (slot) => `slot_${slot}.fs`;
186
+ const mtime = (name) => {
187
+ try {
188
+ return mtimeOf(FS.stat(file(name)).mtime);
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ };
194
+ const checkSlot = (slot) => {
195
+ if (slot === AUTO_STATE_SLOT || (Number.isInteger(slot) && slot >= 0 && slot < STATE_SLOTS))
196
+ return slot;
197
+ throw new RangeError(`fbneo: a state slot is 0-${STATE_SLOTS - 1} or ${AUTO_STATE_SLOT}, not ${slot}`);
198
+ };
199
+ const isLocked = (slot) => exists(`slot_${slot}.lock`);
200
+ const refuseLocked = (slot) => {
201
+ if (isLocked(slot))
202
+ throw new Error(`fbneo: state slot ${slot} is locked`);
203
+ };
204
+ // Removed files have to leave OPFS too: the mirror only ever writes.
205
+ const forget = async (names) => {
206
+ for (const name of names) {
207
+ try {
208
+ FS.unlink(file(name));
209
+ }
210
+ catch {
211
+ /* not there */
212
+ }
213
+ this.#lastSaved.delete(name);
214
+ }
215
+ const dir = this.#persist ? await opfsDir(this.#storagePath, false) : null;
216
+ for (const name of names)
217
+ await dir?.removeEntry(name).catch(() => { });
218
+ };
219
+ const get = (slot) => {
220
+ checkSlot(slot);
221
+ const savedAt = mtime(stateName(slot));
222
+ let thumbnail = null;
223
+ if (savedAt !== null && exists(`slot_${slot}.png`)) {
224
+ thumbnail = new Blob([new Uint8Array(FS.readFile(file(`slot_${slot}.png`)))], { type: 'image/png' });
225
+ }
226
+ return { slot, savedAt: savedAt === null ? null : new Date(savedAt), thumbnail, locked: isLocked(slot) };
227
+ };
228
+ return {
229
+ list: () => Array.from({ length: STATE_SLOTS }, (_, slot) => get(slot)),
230
+ get,
231
+ save: async (slot, options = {}) => {
232
+ checkSlot(slot);
233
+ refuseLocked(slot);
234
+ // FBNeo writes the state at once (between frames: JS only runs while its loop sleeps).
235
+ if (!Module._web_save_state(slot))
236
+ throw new Error('fbneo: the state could not be saved (no game running?)');
237
+ if (options.thumbnail) {
238
+ FS.writeFile(file(`slot_${slot}.png`), new Uint8Array(await options.thumbnail.arrayBuffer()));
239
+ }
240
+ else
241
+ await forget([`slot_${slot}.png`]);
242
+ await this.flushSaves();
243
+ },
244
+ load: (slot) => {
245
+ checkSlot(slot);
246
+ if (mtime(stateName(slot)) === null)
247
+ return false;
248
+ return Module._web_load_state(slot) !== 0;
249
+ },
250
+ copy: async (from, to) => {
251
+ checkSlot(from);
252
+ checkSlot(to);
253
+ refuseLocked(to);
254
+ if (mtime(stateName(from)) === null)
255
+ throw new Error(`fbneo: state slot ${from} is empty`);
256
+ await forget([stateName(to), `slot_${to}.png`]);
257
+ FS.writeFile(file(stateName(to)), new Uint8Array(FS.readFile(file(stateName(from)))));
258
+ if (exists(`slot_${from}.png`)) {
259
+ FS.writeFile(file(`slot_${to}.png`), new Uint8Array(FS.readFile(file(`slot_${from}.png`))));
260
+ }
261
+ await this.flushSaves();
262
+ },
263
+ lock: async (slot, locked) => {
264
+ checkSlot(slot);
265
+ if (locked) {
266
+ if (mtime(stateName(slot)) === null)
267
+ throw new Error(`fbneo: state slot ${slot} is empty`);
268
+ FS.writeFile(file(`slot_${slot}.lock`), new Uint8Array([1]));
269
+ await this.flushSaves();
270
+ }
271
+ else {
272
+ await forget([`slot_${slot}.lock`]);
273
+ this.emit('saves', { files: [`slot_${slot}.lock`] });
274
+ }
275
+ },
276
+ remove: async (slot) => {
277
+ checkSlot(slot);
278
+ refuseLocked(slot);
279
+ const names = [stateName(slot), `slot_${slot}.png`];
280
+ await forget(names);
281
+ this.emit('saves', { files: names });
282
+ },
283
+ };
284
+ }
285
+ #requireModule() {
286
+ if (!this.#module)
287
+ throw new Error('fbneo: the engine is not running — start() it first');
288
+ return this.#module;
289
+ }
290
+ /**
291
+ * Freeze emulation and sound. The first caller owns the pause, and only that
292
+ * owner's {@link resume} lifts it, so an overlay cannot resume a game the host had
293
+ * already frozen. Saves are flushed, since a paused game is often a closed one.
294
+ */
295
+ pause(owner = 'host') {
296
+ const Module = this.#module;
297
+ if (!Module || this.#pauseOwner)
298
+ return;
299
+ this.#pauseOwner = owner;
300
+ Module._web_set_paused(1);
301
+ void Module.SDL2?.audioContext?.suspend();
302
+ void this.flushSaves();
303
+ this.emit('pause', { owner });
304
+ }
305
+ resume(owner = 'host') {
306
+ const Module = this.#module;
307
+ if (!Module || this.#pauseOwner !== owner)
308
+ return;
309
+ this.#pauseOwner = null;
310
+ void Module.SDL2?.audioContext?.resume();
311
+ Module._web_set_paused(0);
312
+ this.emit('resume', { owner });
313
+ }
314
+ /**
315
+ * Ask FBNeo to write its NVRAM, then copy whatever changed in `/save` to OPFS
316
+ * `fbneo/<namespace>/`. Emits `saves` with the files that moved. Safe to call any
317
+ * time; overlapping calls share one flush.
318
+ */
319
+ flushSaves() {
320
+ this.#flushing ??= this.#flush().finally(() => {
321
+ this.#flushing = null;
322
+ });
323
+ return this.#flushing;
324
+ }
325
+ // ---- Contract hooks ----
326
+ mounted(target) {
327
+ if (target instanceof HTMLCanvasElement) {
328
+ this.#canvas = target;
329
+ this.#ownsCanvas = false;
330
+ this.#box = target.parentElement;
331
+ }
332
+ else {
333
+ this.#canvas = document.createElement('canvas');
334
+ this.#ownsCanvas = true;
335
+ this.#box = target;
336
+ target.appendChild(this.#canvas);
337
+ }
338
+ const canvas = this.#canvas;
339
+ // Emscripten's SDL2 finds its window by id, and measures the element: no border or
340
+ // padding on the canvas itself, or SDL creates a tiny window.
66
341
  canvas.id = 'canvas';
67
- if (!canvas.width)
68
- canvas.width = manifest.video.baseWidth;
69
- if (!canvas.height)
70
- canvas.height = manifest.video.baseHeight;
71
- // SDL2's Emscripten backend measures getBoundingClientRect() (borders/padding
72
- // included) to detect external CSS sizing; decoration on the canvas itself
73
- // makes it create a tiny window. Host pages should decorate a wrapper instead.
74
- canvas.style.border = '0';
75
- canvas.style.padding = '0';
76
- canvas.style.imageRendering = renderFilter === 'pixelated' ? 'pixelated' : 'auto';
77
- return canvas;
78
- }
79
- export async function load(config) {
80
- const { assets, onEvent } = config;
81
- const emit = (e) => {
342
+ if (!canvas.width)
343
+ canvas.width = FBNEO_VIDEO.width;
344
+ if (!canvas.height)
345
+ canvas.height = FBNEO_VIDEO.height;
346
+ canvas.tabIndex = 0;
347
+ canvas.style.border = '0';
348
+ canvas.style.padding = '0';
349
+ canvas.style.display = 'block';
350
+ canvas.addEventListener('contextmenu', (event) => event.preventDefault());
351
+ void this.#applyFilter();
352
+ if (this.#box && this.#ownsCanvas) {
353
+ this.#boxObserver?.disconnect();
354
+ this.#boxObserver = new ResizeObserver(() => this.#fit());
355
+ this.#boxObserver.observe(this.#box);
356
+ // FBNeo sizes the canvas to each game's picture (vertical games too): refit then.
357
+ this.#canvasObserver?.disconnect();
358
+ this.#canvasObserver = new MutationObserver(() => this.#fit());
359
+ this.#canvasObserver.observe(canvas, { attributes: true, attributeFilter: ['width', 'height'] });
360
+ this.#fit();
361
+ }
362
+ }
363
+ async open(_options) {
364
+ if (this.#destroyed) {
365
+ throw new Error('fbneo: this engine has been destroyed and cannot be started again in this page — build a new FbneoSDK in a new page');
366
+ }
367
+ if (this.#module) {
368
+ // Already running: a restart is the machine's soft reset.
369
+ if (this.#pauseOwner)
370
+ this.resume(this.#pauseOwner);
371
+ this.#module._web_reset();
372
+ return;
373
+ }
374
+ await this.#boot();
375
+ }
376
+ patched(kind, patch) {
377
+ if (kind === 'config' && 'renderFilter' in patch)
378
+ void this.#applyFilter();
379
+ }
380
+ async close() {
381
+ if (this.#saveTimer)
382
+ clearInterval(this.#saveTimer);
383
+ this.#saveTimer = null;
384
+ document.removeEventListener('visibilitychange', this.#onHidden);
385
+ window.removeEventListener('pagehide', this.#onHidden);
386
+ const Module = this.#module;
387
+ if (Module) {
388
+ await this.flushSaves();
389
+ const context = Module.SDL2?.audioContext;
390
+ try {
391
+ Module._web_set_paused(1);
392
+ // Ends FBNeo's loop: it exits the game its own way (NVRAM written, sound closed).
393
+ Module._web_quit();
394
+ }
395
+ catch {
396
+ /* already gone */
397
+ }
398
+ // FBNeo's exit closes the AudioContext on the loop's next turn; close it here only
399
+ // if it didn't (closing it first makes FBNeo's own close throw).
400
+ const deadline = performance.now() + 1000;
401
+ while (context && context.state !== 'closed' && performance.now() < deadline) {
402
+ await new Promise((resolve) => setTimeout(resolve, 50));
403
+ }
404
+ if (context && context.state !== 'closed')
405
+ await context.close().catch(() => { });
406
+ }
407
+ this.#boxObserver?.disconnect();
408
+ this.#boxObserver = null;
409
+ this.#canvasObserver?.disconnect();
410
+ this.#canvasObserver = null;
411
+ if (this.#ownsCanvas)
412
+ this.#canvas?.remove();
413
+ this.#canvas = null;
414
+ this.#module = null;
415
+ this.#pauseOwner = null;
416
+ this.#destroyed = true;
417
+ }
418
+ // ---- Boot ----
419
+ async #boot() {
420
+ const config = await this.#values('config');
421
+ const options = await this.#values('options');
422
+ const assets = await this.#values('assets');
423
+ const storage = await this.#values('storage');
424
+ if (!this.#canvas) {
425
+ throw new Error('fbneo: mount() an element (or a canvas) before start()');
426
+ }
427
+ const driver = driverName(options);
428
+ if (!driver) {
429
+ throw new Error('fbneo: which game is it? Pass options.driver (the FBNeo short name, e.g. "mslug") ' +
430
+ 'or options.romFileName named after it (mslug.zip).');
431
+ }
432
+ const rom = await resolveAsset(assets.rom);
433
+ if (!rom)
434
+ throw new Error('fbneo: no ROM set — hand one over as assets.rom (bytes or a thunk)');
435
+ const bios = await resolveAsset(assets.bios);
436
+ this.#driver = driver;
437
+ const resolved = storagePath(ENGINE_ID, storage.namespace);
438
+ this.#namespace = resolved.namespace;
439
+ this.#storagePath = resolved.path;
440
+ this.#persist = storage.persist !== null;
441
+ const jsUrl = config.jsUrl ?? new URL('./fbneo.js', import.meta.url).href;
442
+ const wasmUrl = config.wasmUrl ?? new URL('./fbneo.wasm', import.meta.url).href;
443
+ const { default: createFbneoModule } = (await import(/* @vite-ignore */ jsUrl));
444
+ // The context SDL will use, made first so it keeps its picture between frames:
445
+ // FBNeo draws from timers, not animation frames, so without this snapshot() (the
446
+ // app's suspend-point frames) reads a cleared buffer. SDL gets this one back.
447
+ this.#canvas.getContext('webgl', { preserveDrawingBuffer: true, antialias: false });
448
+ let failed = null;
449
+ // FBNeo reports a set it could not load only on stdout (then main() returns).
450
+ let refused = null;
451
+ const Module = await createFbneoModule({
452
+ canvas: this.#canvas,
453
+ noInitialRun: true,
454
+ locateFile: (path) => (path.endsWith('.wasm') ? wasmUrl : new URL(path, jsUrl).href),
455
+ print: (text) => {
456
+ if (/error loading your selected game/i.test(text))
457
+ refused = text;
458
+ console.log('[fbneo]', text);
459
+ },
460
+ printErr: (text) => console.warn('[fbneo]', text),
461
+ onAbort: (what) => failed?.(new Error(`fbneo: aborted — ${String(what)}`)),
462
+ });
463
+ this.#module = Module;
464
+ failed = (error) => this.emit('error', error);
465
+ const { FS } = Module;
466
+ // FBNeo finds a game by `<driver>.zip` in its ROM paths, and matches the files
467
+ // inside by name/CRC: whatever the player named the file, it lands as that.
468
+ for (const dir of ROM_DIRS) {
469
+ FS.mkdirTree(dir);
470
+ FS.writeFile(`${dir}/${driver}.zip`, rom);
471
+ if (bios)
472
+ FS.writeFile(`${dir}/neogeo.zip`, bios);
473
+ }
474
+ FS.mkdirTree(SAVE_DIR);
475
+ if (this.#persist)
476
+ await this.#restoreSaves();
477
+ // callMain() returns at FBNeo's first yield, before the set is loaded: wait for
478
+ // the game to run, or for FBNeo to say it refused the set, so a bad set is an
479
+ // error here and not a black screen.
480
+ Module.callMain([driver]);
481
+ const deadline = performance.now() + LOAD_TIMEOUT_MS;
482
+ while (!Module._web_running() && !refused && performance.now() < deadline) {
483
+ await new Promise((resolve) => setTimeout(resolve, 50));
484
+ }
485
+ if (!Module._web_running()) {
486
+ throw new Error(refused
487
+ ? `fbneo: FBNeo could not load "${driver}" — the ROM set is incomplete, is not that driver, or needs a BIOS (neogeo.zip for Neo Geo)`
488
+ : `fbneo: "${driver}" did not start within ${LOAD_TIMEOUT_MS / 1000} s`);
489
+ }
490
+ if (this.#persist) {
491
+ this.#saveTimer = setInterval(() => void this.flushSaves(), SAVE_INTERVAL_MS);
492
+ document.addEventListener('visibilitychange', this.#onHidden);
493
+ window.addEventListener('pagehide', this.#onHidden);
494
+ }
495
+ this.#fit();
496
+ }
497
+ /** The plain values behind one payload's descriptors. */
498
+ async #values(kind) {
499
+ const described = (await this[kind]());
500
+ const out = {};
501
+ for (const [key, property] of Object.entries(described))
502
+ out[key] = property.value;
503
+ return out;
504
+ }
505
+ // ---- Saves ----
506
+ async #restoreSaves() {
507
+ const dir = await opfsDir(this.#storagePath, false);
508
+ if (!dir || !this.#module)
509
+ return;
510
+ const iterable = dir;
511
+ for await (const [name, handle] of iterable.entries()) {
512
+ if (handle.kind !== 'file')
513
+ continue;
514
+ const bytes = new Uint8Array(await (await handle.getFile()).arrayBuffer());
515
+ this.#module.FS.writeFile(`${SAVE_DIR}/${name}`, bytes);
516
+ this.#lastSaved.set(name, bytes);
517
+ }
518
+ }
519
+ async #flush() {
520
+ const Module = this.#module;
521
+ if (!Module || !this.#persist)
522
+ return;
82
523
  try {
83
- onEvent?.(e);
524
+ Module._web_persist();
84
525
  }
85
- catch {
86
- // host callback must not break the engine runtime
526
+ catch (e) {
527
+ console.warn('[fbneo] persist failed', e);
528
+ return;
87
529
  }
88
- };
89
- // Generic hosts (e.g. the engine-specs demo shell) pass the picked file's name
90
- // as `options.fileName`; treat it as an alias for `romFileName` so the driver
91
- // can be inferred from the ROM's filename without a FBNeo-specific option.
92
- const rawOptions = config.options;
93
- const opts = {
94
- ...DEFAULT_FBNEO_OPTIONS,
95
- ...rawOptions,
96
- romFileName: rawOptions?.romFileName ??
97
- rawOptions?.fileName ??
98
- DEFAULT_FBNEO_OPTIONS.romFileName,
99
- };
100
- const romBytes = toUint8(assets?.rom ?? assets?.data);
101
- if (!romBytes) {
102
- throw new Error('fbneo: no ROM bytes provided — pass assets.rom (an arcade ROM zip)');
103
- }
104
- const biosBytes = toUint8(assets?.bios);
105
- const canvas = prepareCanvas(resolveCanvas(config), opts.renderFilter);
106
- // FBNeo boots a game by its ROM-set short name ("driver", e.g. "mslug") and
107
- // searches its ROM paths for a matching `<driver>.zip`. Prefer an explicit
108
- // `options.driver`; otherwise derive it from the ROM filename (stripping the
109
- // extension). Whatever the source, the zip is mounted as `<driver>.zip` so the
110
- // name FBNeo looks up and the name on disk always agree.
111
- const driver = (opts.driver || driverFromFileName(opts.romFileName)).trim();
112
- if (!driver || driver === 'game') {
113
- throw new Error('fbneo: could not determine the ROM-set name. Pass options.driver (the FBNeo ' +
114
- 'short name, e.g. "mslug") or name the ROM file after it (e.g. mslug.zip).');
115
- }
116
- const romFileName = `${driver}.zip`;
117
- const jsUrl = config.jsUrl ?? new URL('./fbneo.js', import.meta.url).href;
118
- const wasmUrl = config.wasmUrl ?? new URL('./fbneo.wasm', jsUrl).href;
119
- let modRef = null;
120
- let readyResolve = () => { };
121
- const readyPromise = new Promise((resolve) => {
122
- readyResolve = resolve;
123
- });
124
- // FBNeo's default ROM search paths (src/burner/sdl/drv.cpp) are the absolute
125
- // "/usr/local/share/roms/" and the cwd-relative "roms/". Under Emscripten the
126
- // cwd is not guaranteed to be "/", so write to the absolute path (always
127
- // searched) as well as "/roms/". FBNeo matches ROM files inside each zip by
128
- // name/CRC, so the zip must be named after the driver.
129
- const romDirs = ['/usr/local/share/roms', '/roms'];
130
- const writeRoms = (mod) => {
131
- for (const dir of romDirs) {
132
- mod.FS.mkdirTree(dir);
133
- mod.FS.writeFile(`${dir}/${romFileName}`, romBytes);
134
- if (biosBytes)
135
- mod.FS.writeFile(`${dir}/neogeo.zip`, biosBytes);
530
+ const changed = [];
531
+ for (const name of Module.FS.readdir(SAVE_DIR)) {
532
+ if (name === '.' || name === '..')
533
+ continue;
534
+ const path = `${SAVE_DIR}/${name}`;
535
+ if (!Module.FS.isFile(Module.FS.stat(path).mode))
536
+ continue;
537
+ const bytes = new Uint8Array(Module.FS.readFile(path));
538
+ if (!sameBytes(this.#lastSaved.get(name), bytes))
539
+ changed.push([name, bytes]);
136
540
  }
137
- };
138
- const moduleOverrides = {
139
- canvas,
140
- noInitialRun: true,
141
- locateFile(path) {
142
- if (path.endsWith('.wasm'))
143
- return wasmUrl;
144
- return new URL(path, jsUrl).href;
145
- },
146
- };
147
- await loadClassicScriptOnce(jsUrl);
148
- const g = globalThis;
149
- // Our build is a MODULARIZE factory (createFbneoModule). The factory promise
150
- // resolves *after* the runtime is initialized and FS is available, so this is
151
- // the correct point to populate MEMFS — write the ROMs before callMain().
152
- if (typeof g.createFbneoModule === 'function') {
153
- modRef = await g.createFbneoModule(moduleOverrides);
154
- }
155
- else if (g.Module) {
156
- modRef = g.Module;
157
- }
158
- else {
159
- throw new Error('fbneo: unable to initialize runtime module from fbneo.js');
160
- }
161
- writeRoms(modRef);
162
- readyResolve();
163
- await readyPromise;
164
- let running = false;
165
- const boot = () => {
166
- if (running)
541
+ if (changed.length === 0)
167
542
  return;
168
- running = true;
169
- // FBNeo SDL takes the ROM set short name and searches its rom paths for it.
170
- modRef.callMain([driver]);
171
- emit({ type: 'ready' });
172
- };
173
- const setInput = (_map) => {
174
- // Input is handled by the Emscripten SDL2 event loop; no external override yet.
175
- };
176
- return {
177
- start() {
178
- boot();
179
- },
180
- pause() {
181
- modRef?.pauseMainLoop?.();
182
- },
183
- resume() {
184
- modRef?.resumeMainLoop?.();
185
- },
186
- reset() {
187
- // FBNeo does not expose a soft-reset hook through this WASM build.
188
- throw new Error('fbneo: reset is not supported by this build');
189
- },
190
- setInput,
191
- destroy() {
192
- modRef?.pauseMainLoop?.();
193
- running = false;
194
- emit({ type: 'exit' });
195
- },
196
- };
543
+ const dir = await opfsDir(this.#storagePath, true);
544
+ if (!dir)
545
+ return;
546
+ const written = [];
547
+ for (const [name, bytes] of changed) {
548
+ try {
549
+ const writable = await (await dir.getFileHandle(name, { create: true })).createWritable();
550
+ await writable.write(bytes);
551
+ await writable.close();
552
+ this.#lastSaved.set(name, bytes);
553
+ written.push(name);
554
+ }
555
+ catch (e) {
556
+ console.warn(`[fbneo] could not persist ${name}`, e);
557
+ }
558
+ }
559
+ if (written.length)
560
+ this.emit('saves', { files: written });
561
+ }
562
+ // ---- Picture ----
563
+ /** Scale the canvas to fit its box, keeping the picture's own proportions. */
564
+ #fit() {
565
+ const canvas = this.#canvas;
566
+ const box = this.#box;
567
+ if (!canvas || !box || !this.#ownsCanvas)
568
+ return;
569
+ const { width, height } = box.getBoundingClientRect();
570
+ const w = canvas.width || FBNEO_VIDEO.width;
571
+ const h = canvas.height || FBNEO_VIDEO.height;
572
+ if (!width || !height)
573
+ return;
574
+ const scale = Math.min(width / w, height / h);
575
+ canvas.style.width = `${Math.floor(w * scale)}px`;
576
+ canvas.style.height = `${Math.floor(h * scale)}px`;
577
+ }
578
+ async #applyFilter() {
579
+ const canvas = this.#canvas;
580
+ if (!canvas)
581
+ return;
582
+ const { renderFilter } = await this.#values('config');
583
+ canvas.style.imageRendering = renderFilter === 'smooth' ? 'auto' : 'pixelated';
584
+ }
585
+ }
586
+ /**
587
+ * The factory a host constructs: static identity, the configuration chain, and a
588
+ * `start()` that boots FBNeo and opens a session.
589
+ */
590
+ export class FbneoSDK extends EngineSDKBase {
591
+ static id = ENGINE_ID;
592
+ /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
593
+ static version = '0.2.0';
594
+ static name = 'FinalBurn Neo';
595
+ static description = 'FBNeo, the arcade emulator (Capcom CPS-1/2/3, SNK Neo Geo, Sega System 16 and more), compiled to WebAssembly. Ships no games: the player provides the ROM sets, and they never leave their browser.';
596
+ createPlay(init, events) {
597
+ return new FbneoPlay(init, events);
598
+ }
197
599
  }
198
- export default { manifest, load };
600
+ /** CI's conformance line: if this class stops satisfying the contract, `make test` fails. */
601
+ export const FBNEO_ENGINE = FbneoSDK;
602
+ export default FbneoSDK;
199
603
  //# sourceMappingURL=fbneo.sdk.js.map