@wasm-gaming/fbneo-wasm 0.1.2 → 0.3.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 +112 -69
- package/dist/fbneo/app/app.css +112 -0
- package/dist/fbneo/app/app.js +646 -0
- package/dist/fbneo/engine-specs.js +646 -0
- package/dist/fbneo/fbneo.js +2 -0
- package/dist/fbneo/fbneo.options.d.ts +72 -19
- package/dist/fbneo/fbneo.options.d.ts.map +1 -1
- package/dist/fbneo/fbneo.options.js +49 -48
- package/dist/fbneo/fbneo.options.js.map +1 -1
- package/dist/fbneo/fbneo.sdk.d.ts +157 -8
- package/dist/fbneo/fbneo.sdk.d.ts.map +1 -1
- package/dist/fbneo/fbneo.sdk.js +641 -181
- package/dist/fbneo/fbneo.sdk.js.map +1 -1
- package/dist/fbneo/fbneo.wasm +4 -0
- package/dist/fbneo/romset.d.ts +0 -0
- package/dist/fbneo/romset.d.ts.map +1 -1
- package/dist/fbneo/romset.js.map +1 -1
- package/{data → dist/fbneo}/romsets/atari.json +5036 -118
- package/{data → dist/fbneo}/romsets/capcom.json +3392 -848
- package/{data → dist/fbneo}/romsets/cave.json +460 -115
- package/{data → dist/fbneo}/romsets/channelf.json +200 -50
- package/{data → dist/fbneo}/romsets/coleco.json +2650 -658
- package/{data → dist/fbneo}/romsets/cps3.json +240 -60
- package/{data → dist/fbneo}/romsets/dataeast.json +1352 -338
- package/{data → dist/fbneo}/romsets/galaxian.json +1192 -298
- package/dist/fbneo/romsets/gba.json +197 -0
- package/{data → dist/fbneo}/romsets/index.json +16 -12
- package/{data → dist/fbneo}/romsets/irem.json +796 -199
- package/{data → dist/fbneo}/romsets/konami.json +1644 -411
- package/{data → dist/fbneo}/romsets/megadrive.json +11899 -2926
- package/{data → dist/fbneo}/romsets/midway.json +560 -140
- package/{data → dist/fbneo}/romsets/msx.json +7204 -1798
- package/{data → dist/fbneo}/romsets/neogeo.json +2704 -614
- package/{data → dist/fbneo}/romsets/nes.json +12815 -3155
- package/{data → dist/fbneo}/romsets/pce.json +1952 -488
- package/{data → dist/fbneo}/romsets/pgm.json +796 -199
- package/{data → dist/fbneo}/romsets/pgm2.json +32 -8
- package/{data → dist/fbneo}/romsets/pre90s.json +10002 -2084
- package/{data → dist/fbneo}/romsets/psikyo.json +160 -40
- package/{data → dist/fbneo}/romsets/pst90s.json +6400 -1597
- package/{data → dist/fbneo}/romsets/sega.json +2756 -658
- package/{data → dist/fbneo}/romsets/sg1000.json +1032 -255
- package/{data → dist/fbneo}/romsets/sms.json +6012 -1503
- package/{data → dist/fbneo}/romsets/snes.json +10991 -2816
- package/{data → dist/fbneo}/romsets/spectrum.json +13579 -3361
- package/{data → dist/fbneo}/romsets/taito.json +2496 -624
- package/{data → dist/fbneo}/romsets/toaplan.json +576 -144
- package/package.json +10 -15
- package/src/app/app.css +112 -0
- package/src/app/app.js +646 -0
- package/src/fbneo.options.ts +119 -63
- package/src/fbneo.sdk.ts +712 -211
- package/src/romset.ts +0 -0
- package/dist/fbneo/fbneo.manifest.d.ts +0 -4
- package/dist/fbneo/fbneo.manifest.d.ts.map +0 -1
- package/dist/fbneo/fbneo.manifest.js +0 -33
- package/dist/fbneo/fbneo.manifest.js.map +0 -1
- package/dist/manifest.json +0 -91
- package/src/fbneo.manifest.ts +0 -38
package/src/fbneo.sdk.ts
CHANGED
|
@@ -1,256 +1,757 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
31
|
+
EnginePlayBase,
|
|
32
|
+
EngineSDKBase,
|
|
33
|
+
EventEmitter,
|
|
34
|
+
type EngineInit,
|
|
35
|
+
type EngineSDKConstructor,
|
|
36
|
+
type EventsOf,
|
|
8
37
|
} from '@wasm-gaming/engine-specs';
|
|
9
|
-
import {
|
|
10
|
-
|
|
38
|
+
import {
|
|
39
|
+
FBNEO_SPEC,
|
|
40
|
+
FBNEO_VIDEO,
|
|
41
|
+
type FbneoAsset,
|
|
42
|
+
type FbneoConfig,
|
|
43
|
+
type FbneoOptions,
|
|
44
|
+
type FbneoPayloads,
|
|
45
|
+
} from './fbneo.options.js';
|
|
11
46
|
|
|
12
|
-
export {
|
|
47
|
+
export type {
|
|
48
|
+
FbneoAsset,
|
|
49
|
+
FbneoAssets,
|
|
50
|
+
FbneoConfig,
|
|
51
|
+
FbneoEvents,
|
|
52
|
+
FbneoOptions,
|
|
53
|
+
FbneoPayloads,
|
|
54
|
+
FbneoStorage,
|
|
55
|
+
} from './fbneo.options.js';
|
|
56
|
+
export { FBNEO_SPEC, FBNEO_VIDEO } from './fbneo.options.js';
|
|
57
|
+
import { parseZipEntries } from './romset.js';
|
|
13
58
|
|
|
59
|
+
const ENGINE_ID = 'fbneo';
|
|
60
|
+
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
61
|
+
const SAVE_DIR = '/save';
|
|
14
62
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* FBNeo's Emscripten SDL2 build is expected to be produced with
|
|
18
|
-
* `-sMODULARIZE -sEXPORT_NAME=createFbneoModule` and the full `FS` object
|
|
19
|
-
* exported (`-sEXPORTED_RUNTIME_METHODS=FS,callMain`). We defensively support
|
|
20
|
-
* both the modularized factory and the classic global-`Module` shape.
|
|
63
|
+
* FBNeo's ROM search paths (src/burner/sdl/drv.cpp): the absolute one is always
|
|
64
|
+
* searched; `/roms` covers the cwd-relative `roms/` with the cwd at `/`.
|
|
21
65
|
*/
|
|
22
|
-
|
|
66
|
+
const ROM_DIRS = ['/usr/local/share/roms', '/roms'];
|
|
67
|
+
const SAVE_INTERVAL_MS = 10_000;
|
|
68
|
+
/** How long a ROM set may take to load before start() gives up. */
|
|
69
|
+
const LOAD_TIMEOUT_MS = 30_000;
|
|
70
|
+
|
|
71
|
+
/** The Emscripten module this build produces (`createFbneoModule` from fbneo.js). */
|
|
72
|
+
interface FbneoModule {
|
|
23
73
|
FS: {
|
|
24
74
|
mkdirTree(path: string): void;
|
|
25
75
|
writeFile(path: string, data: Uint8Array): void;
|
|
26
|
-
|
|
76
|
+
readFile(path: string): Uint8Array;
|
|
27
77
|
readdir(path: string): string[];
|
|
78
|
+
stat(path: string): { mode: number; mtime: Date | number };
|
|
79
|
+
unlink(path: string): void;
|
|
80
|
+
isFile(mode: number): boolean;
|
|
28
81
|
};
|
|
29
82
|
callMain(args: string[]): void;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const scriptLoadCache = new Map<string, Promise<void>>();
|
|
41
|
-
|
|
42
|
-
function loadClassicScriptOnce(src: string): Promise<void> {
|
|
43
|
-
const cached = scriptLoadCache.get(src);
|
|
44
|
-
if (cached) return cached;
|
|
45
|
-
|
|
46
|
-
const p = new Promise<void>((resolve, reject) => {
|
|
47
|
-
const script = document.createElement('script');
|
|
48
|
-
script.src = src;
|
|
49
|
-
script.async = true;
|
|
50
|
-
script.onload = () => resolve();
|
|
51
|
-
script.onerror = () => reject(new Error(`fbneo: failed to load script: ${src}`));
|
|
52
|
-
document.head.appendChild(script);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
scriptLoadCache.set(src, p);
|
|
56
|
-
return p;
|
|
83
|
+
_web_set_paused(paused: number): void;
|
|
84
|
+
_web_reset(): void;
|
|
85
|
+
_web_persist(): number;
|
|
86
|
+
_web_save_state(slot: number): number;
|
|
87
|
+
_web_load_state(slot: number): number;
|
|
88
|
+
_web_running(): number;
|
|
89
|
+
_web_quit(): void;
|
|
90
|
+
_web_memcard_insert(): number;
|
|
91
|
+
ccall(name: string, returnType: 'number', argTypes: string[], args: unknown[]): number;
|
|
92
|
+
SDL2?: { audioContext?: AudioContext };
|
|
57
93
|
}
|
|
58
94
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
type FbneoFactory = (overrides: Record<string, unknown>) => Promise<FbneoModule>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
|
|
99
|
+
* root. Identical to the other engines': segments cleaned, `.` and `..` dropped,
|
|
100
|
+
* anything outside `[A-Za-z0-9._-]` → `_`, nothing usable → `default`; a leading
|
|
101
|
+
* `<engineId>/` is stripped once.
|
|
102
|
+
*/
|
|
103
|
+
export function storagePath(
|
|
104
|
+
engineId: string,
|
|
105
|
+
namespace: unknown,
|
|
106
|
+
): { namespace: string; path: string; prefixed: boolean } {
|
|
107
|
+
const segments =
|
|
108
|
+
typeof namespace === 'string'
|
|
109
|
+
? namespace
|
|
110
|
+
.split('/')
|
|
111
|
+
.map((segment) => segment.trim())
|
|
112
|
+
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
|
113
|
+
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
114
|
+
: [];
|
|
115
|
+
const prefixed = segments.length > 1 && segments[0] === engineId;
|
|
116
|
+
if (prefixed) segments.shift();
|
|
117
|
+
const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
|
|
118
|
+
return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
|
|
66
119
|
}
|
|
67
120
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The FBNeo driver for a session: `driver` when given, else the ROM file name's stem
|
|
123
|
+
* (`mslug.zip`, `C:\roms\mslug.7z` → `mslug`). Empty when neither says.
|
|
124
|
+
*/
|
|
125
|
+
export function driverName(options: { driver?: string; romFileName?: string }): string {
|
|
126
|
+
const explicit = options.driver?.trim();
|
|
127
|
+
if (explicit) return explicit;
|
|
128
|
+
const base = (options.romFileName ?? '').replace(/^.*[\\/]/, '');
|
|
129
|
+
return base.replace(/\.(zip|7z)$/i, '').trim();
|
|
72
130
|
}
|
|
73
131
|
|
|
132
|
+
async function opfsDir(path: string, create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
|
133
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
134
|
+
try {
|
|
135
|
+
let dir = await navigator.storage.getDirectory();
|
|
136
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
137
|
+
dir = await dir.getDirectoryHandle(segment, { create });
|
|
138
|
+
}
|
|
139
|
+
return dir;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveAsset(asset: FbneoAsset | undefined): Promise<Uint8Array | null> {
|
|
146
|
+
const value = typeof asset === 'function' ? await asset() : asset;
|
|
147
|
+
if (value == null) return null;
|
|
148
|
+
if (value instanceof Uint8Array) return value;
|
|
149
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
150
|
+
throw new TypeError('fbneo: an asset must be a Uint8Array, an ArrayBuffer or a thunk returning one');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
|
|
154
|
+
if (!a || a.length !== b.length) return false;
|
|
155
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Neo Geo BIOS FBNeo should run, from what the BIOS zip holds (by CRC). */
|
|
160
|
+
export interface NeoGeoBiosChoice {
|
|
161
|
+
/** `aes`: the real AES BIOS; `unibios`: Universe BIOS 4.0 in its AES mode. */
|
|
162
|
+
bios: 'aes' | 'unibios';
|
|
163
|
+
/** The driver's settings to apply: `[group, option]`, as FBNeo names them. */
|
|
164
|
+
dips: Array<[string, string]>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const AES_WORLD_CRC = 'd27a71f1'; // neo-epo.bin
|
|
168
|
+
const UNIBIOS_40_CRC = 'a7aab458'; // uni-bios_4_0.rom
|
|
169
|
+
|
|
74
170
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `attachTo` (a container the SDK mounts its own canvas into). Legacy `canvas`
|
|
78
|
-
* is still accepted as a defensive fallback for hosts built against 0.1.x.
|
|
171
|
+
* How to boot a Neo Geo as the AES (the home console) with the BIOS zip at hand: the
|
|
172
|
+
* real AES World BIOS when it is there, else Universe BIOS 4.0 in AES mode, else null.
|
|
79
173
|
*/
|
|
80
|
-
function
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const attachTo = (config as { attachTo?: HTMLElement }).attachTo;
|
|
87
|
-
if (attachTo) {
|
|
88
|
-
const existing = attachTo.querySelector('canvas');
|
|
89
|
-
if (existing) return existing;
|
|
90
|
-
const created = document.createElement('canvas');
|
|
91
|
-
attachTo.appendChild(created);
|
|
92
|
-
return created;
|
|
174
|
+
export function neoGeoAesBios(entries: Array<{ crc: string }>): NeoGeoBiosChoice | null {
|
|
175
|
+
const crcs = new Set(entries.map((entry) => entry.crc.toLowerCase()));
|
|
176
|
+
if (crcs.has(AES_WORLD_CRC)) return { bios: 'aes', dips: [['BIOS', 'AES World']] };
|
|
177
|
+
if (crcs.has(UNIBIOS_40_CRC)) {
|
|
178
|
+
return { bios: 'unibios', dips: [['BIOS', 'Universe BIOS (Hack, Ver. 4.0)'], ['Unibios mode', 'AES']] };
|
|
93
179
|
}
|
|
94
|
-
|
|
180
|
+
return null;
|
|
95
181
|
}
|
|
96
182
|
|
|
183
|
+
/** The player's save-state slots: 0-3. */
|
|
184
|
+
export const STATE_SLOTS = 4;
|
|
97
185
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
186
|
+
* The *unsaved* suspend point: written when the player leaves, offered when they come
|
|
187
|
+
* back, and saved into a numbered slot only when they ask.
|
|
100
188
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
canvas.style.padding = '0';
|
|
113
|
-
canvas.style.imageRendering = renderFilter === 'pixelated' ? 'pixelated' : 'auto';
|
|
114
|
-
return canvas;
|
|
189
|
+
export const AUTO_STATE_SLOT = 9;
|
|
190
|
+
|
|
191
|
+
/** One save-state slot. */
|
|
192
|
+
export interface FbneoStateSlot {
|
|
193
|
+
slot: number;
|
|
194
|
+
/** When the state was written, or null for an empty slot. */
|
|
195
|
+
savedAt: Date | null;
|
|
196
|
+
/** The frame saved with it (PNG), when the host handed one over. */
|
|
197
|
+
thumbnail: Blob | null;
|
|
198
|
+
/** Protected: cannot be overwritten or removed until unlocked. */
|
|
199
|
+
locked: boolean;
|
|
115
200
|
}
|
|
116
201
|
|
|
117
|
-
|
|
118
|
-
|
|
202
|
+
/** Save states: whole-machine snapshots kept in the save folder (and OPFS). */
|
|
203
|
+
export interface FbneoStates {
|
|
204
|
+
/** The player's slots, 0-3. */
|
|
205
|
+
list(): FbneoStateSlot[];
|
|
206
|
+
/** Any slot, {@link AUTO_STATE_SLOT} included. */
|
|
207
|
+
get(slot: number): FbneoStateSlot;
|
|
208
|
+
/** Save the running game into `slot` (paused or not). Refuses a locked slot. */
|
|
209
|
+
save(slot: number, options?: { thumbnail?: Blob | null }): Promise<void>;
|
|
210
|
+
/** Load `slot` into the running game; false when it is empty or FBNeo refused it. */
|
|
211
|
+
load(slot: number): boolean;
|
|
212
|
+
/** Copy a state (and its frame) into another slot — the unsaved point into a numbered one. */
|
|
213
|
+
copy(from: number, to: number): Promise<void>;
|
|
214
|
+
lock(slot: number, locked: boolean): Promise<void>;
|
|
215
|
+
remove(slot: number): Promise<void>;
|
|
216
|
+
}
|
|
119
217
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
218
|
+
const nextFrame = (): Promise<void> => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
219
|
+
const mtimeOf = (value: Date | number | undefined): number =>
|
|
220
|
+
value instanceof Date ? value.getTime() : typeof value === 'number' ? value : 0;
|
|
221
|
+
|
|
222
|
+
/** The live engine: one booted FBNeo and its session. */
|
|
223
|
+
export class FbneoPlay extends EnginePlayBase<FbneoPayloads> {
|
|
224
|
+
#module: FbneoModule | null = null;
|
|
225
|
+
#destroyed = false;
|
|
226
|
+
#pauseOwner: string | null = null;
|
|
227
|
+
#driver = '';
|
|
228
|
+
#neogeo: { mode: 'aes'; bios: 'aes' | 'unibios'; card: boolean } | null = null;
|
|
229
|
+
|
|
230
|
+
#canvas: HTMLCanvasElement | null = null;
|
|
231
|
+
#ownsCanvas = false;
|
|
232
|
+
#box: HTMLElement | null = null;
|
|
233
|
+
#boxObserver: ResizeObserver | null = null;
|
|
234
|
+
#canvasObserver: MutationObserver | null = null;
|
|
127
235
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...rawOptions,
|
|
137
|
-
romFileName:
|
|
138
|
-
rawOptions?.romFileName ??
|
|
139
|
-
rawOptions?.fileName ??
|
|
140
|
-
DEFAULT_FBNEO_OPTIONS.romFileName,
|
|
236
|
+
#namespace = DEFAULT_STORAGE_NAMESPACE;
|
|
237
|
+
#storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
238
|
+
#persist = true;
|
|
239
|
+
#saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
240
|
+
#lastSaved = new Map<string, Uint8Array>();
|
|
241
|
+
#flushing: Promise<void> | null = null;
|
|
242
|
+
#onHidden = (): void => {
|
|
243
|
+
if (document.visibilityState === 'hidden') void this.flushSaves();
|
|
141
244
|
};
|
|
142
245
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
246
|
+
constructor(init?: EngineInit<FbneoPayloads>, events?: EventEmitter<EventsOf<FbneoPayloads>>) {
|
|
247
|
+
super(FBNEO_SPEC, init, events);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The normalised namespace this session uses. */
|
|
251
|
+
get namespace(): string {
|
|
252
|
+
return this.#namespace;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Where this session's saves live, relative to the OPFS root: `fbneo/<namespace>`. */
|
|
256
|
+
get storagePath(): string {
|
|
257
|
+
return this.#storagePath;
|
|
146
258
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
259
|
+
|
|
260
|
+
/** The driver (ROM-set short name) this session booted. */
|
|
261
|
+
get driver(): string {
|
|
262
|
+
return this.#driver;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** How a Neo Geo set was booted as the AES: `{ mode, bios, card }`; null otherwise. */
|
|
266
|
+
get neogeo(): { mode: 'aes'; bios: 'aes' | 'unibios'; card: boolean } | null {
|
|
267
|
+
return this.#neogeo;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Who holds the pause, or null while running. */
|
|
271
|
+
get pausedBy(): string | null {
|
|
272
|
+
return this.#pauseOwner;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The current frame as a PNG (also while paused: the picture is kept). Null when
|
|
277
|
+
* there is nothing to read.
|
|
278
|
+
*/
|
|
279
|
+
async snapshot(): Promise<Blob | null> {
|
|
280
|
+
const canvas = this.#canvas;
|
|
281
|
+
if (!canvas || !this.#module) return null;
|
|
282
|
+
await nextFrame();
|
|
283
|
+
const copy = document.createElement('canvas');
|
|
284
|
+
copy.width = canvas.width || FBNEO_VIDEO.width;
|
|
285
|
+
copy.height = canvas.height || FBNEO_VIDEO.height;
|
|
286
|
+
copy.getContext('2d')?.drawImage(canvas, 0, 0, copy.width, copy.height);
|
|
287
|
+
return new Promise((resolve) => copy.toBlob((blob) => resolve(blob), 'image/png'));
|
|
162
288
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
289
|
+
|
|
290
|
+
/** Save states — see {@link FbneoStates}. */
|
|
291
|
+
get states(): FbneoStates {
|
|
292
|
+
const Module = this.#requireModule();
|
|
293
|
+
const { FS } = Module;
|
|
294
|
+
const file = (name: string): string => `${SAVE_DIR}/${name}`;
|
|
295
|
+
const exists = (name: string): boolean => {
|
|
296
|
+
try {
|
|
297
|
+
FS.stat(file(name));
|
|
298
|
+
return true;
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const stateName = (slot: number): string => `slot_${slot}.fs`;
|
|
304
|
+
const mtime = (name: string): number | null => {
|
|
305
|
+
try {
|
|
306
|
+
return mtimeOf(FS.stat(file(name)).mtime);
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const checkSlot = (slot: number): number => {
|
|
312
|
+
if (slot === AUTO_STATE_SLOT || (Number.isInteger(slot) && slot >= 0 && slot < STATE_SLOTS)) return slot;
|
|
313
|
+
throw new RangeError(`fbneo: a state slot is 0-${STATE_SLOTS - 1} or ${AUTO_STATE_SLOT}, not ${slot}`);
|
|
314
|
+
};
|
|
315
|
+
const isLocked = (slot: number): boolean => exists(`slot_${slot}.lock`);
|
|
316
|
+
const refuseLocked = (slot: number): void => {
|
|
317
|
+
if (isLocked(slot)) throw new Error(`fbneo: state slot ${slot} is locked`);
|
|
318
|
+
};
|
|
319
|
+
// Removed files have to leave OPFS too: the mirror only ever writes.
|
|
320
|
+
const forget = async (names: string[]): Promise<void> => {
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
try {
|
|
323
|
+
FS.unlink(file(name));
|
|
324
|
+
} catch {
|
|
325
|
+
/* not there */
|
|
326
|
+
}
|
|
327
|
+
this.#lastSaved.delete(name);
|
|
328
|
+
}
|
|
329
|
+
const dir = this.#persist ? await opfsDir(this.#storagePath, false) : null;
|
|
330
|
+
for (const name of names) await dir?.removeEntry(name).catch(() => {});
|
|
331
|
+
};
|
|
332
|
+
const get = (slot: number): FbneoStateSlot => {
|
|
333
|
+
checkSlot(slot);
|
|
334
|
+
const savedAt = mtime(stateName(slot));
|
|
335
|
+
let thumbnail: Blob | null = null;
|
|
336
|
+
if (savedAt !== null && exists(`slot_${slot}.png`)) {
|
|
337
|
+
thumbnail = new Blob([new Uint8Array(FS.readFile(file(`slot_${slot}.png`)))], { type: 'image/png' });
|
|
338
|
+
}
|
|
339
|
+
return { slot, savedAt: savedAt === null ? null : new Date(savedAt), thumbnail, locked: isLocked(slot) };
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
list: () => Array.from({ length: STATE_SLOTS }, (_, slot) => get(slot)),
|
|
343
|
+
get,
|
|
344
|
+
save: async (slot, options = {}) => {
|
|
345
|
+
checkSlot(slot);
|
|
346
|
+
refuseLocked(slot);
|
|
347
|
+
// FBNeo writes the state at once (between frames: JS only runs while its loop sleeps).
|
|
348
|
+
if (!Module._web_save_state(slot)) throw new Error('fbneo: the state could not be saved (no game running?)');
|
|
349
|
+
if (options.thumbnail) {
|
|
350
|
+
FS.writeFile(file(`slot_${slot}.png`), new Uint8Array(await options.thumbnail.arrayBuffer()));
|
|
351
|
+
} else await forget([`slot_${slot}.png`]);
|
|
352
|
+
await this.flushSaves();
|
|
353
|
+
},
|
|
354
|
+
load: (slot) => {
|
|
355
|
+
checkSlot(slot);
|
|
356
|
+
if (mtime(stateName(slot)) === null) return false;
|
|
357
|
+
return Module._web_load_state(slot) !== 0;
|
|
358
|
+
},
|
|
359
|
+
copy: async (from, to) => {
|
|
360
|
+
checkSlot(from);
|
|
361
|
+
checkSlot(to);
|
|
362
|
+
refuseLocked(to);
|
|
363
|
+
if (mtime(stateName(from)) === null) throw new Error(`fbneo: state slot ${from} is empty`);
|
|
364
|
+
await forget([stateName(to), `slot_${to}.png`]);
|
|
365
|
+
FS.writeFile(file(stateName(to)), new Uint8Array(FS.readFile(file(stateName(from)))));
|
|
366
|
+
if (exists(`slot_${from}.png`)) {
|
|
367
|
+
FS.writeFile(file(`slot_${to}.png`), new Uint8Array(FS.readFile(file(`slot_${from}.png`))));
|
|
368
|
+
}
|
|
369
|
+
await this.flushSaves();
|
|
370
|
+
},
|
|
371
|
+
lock: async (slot, locked) => {
|
|
372
|
+
checkSlot(slot);
|
|
373
|
+
if (locked) {
|
|
374
|
+
if (mtime(stateName(slot)) === null) throw new Error(`fbneo: state slot ${slot} is empty`);
|
|
375
|
+
FS.writeFile(file(`slot_${slot}.lock`), new Uint8Array([1]));
|
|
376
|
+
await this.flushSaves();
|
|
377
|
+
} else {
|
|
378
|
+
await forget([`slot_${slot}.lock`]);
|
|
379
|
+
this.emit('saves', { files: [`slot_${slot}.lock`] });
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
remove: async (slot) => {
|
|
383
|
+
checkSlot(slot);
|
|
384
|
+
refuseLocked(slot);
|
|
385
|
+
const names = [stateName(slot), `slot_${slot}.png`];
|
|
386
|
+
await forget(names);
|
|
387
|
+
this.emit('saves', { files: names });
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#requireModule(): FbneoModule {
|
|
393
|
+
if (!this.#module) throw new Error('fbneo: the engine is not running — start() it first');
|
|
394
|
+
return this.#module;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Freeze emulation and sound. The first caller owns the pause, and only that
|
|
399
|
+
* owner's {@link resume} lifts it, so an overlay cannot resume a game the host had
|
|
400
|
+
* already frozen. Saves are flushed, since a paused game is often a closed one.
|
|
401
|
+
*/
|
|
402
|
+
pause(owner = 'host'): void {
|
|
403
|
+
const Module = this.#module;
|
|
404
|
+
if (!Module || this.#pauseOwner) return;
|
|
405
|
+
this.#pauseOwner = owner;
|
|
406
|
+
Module._web_set_paused(1);
|
|
407
|
+
void Module.SDL2?.audioContext?.suspend();
|
|
408
|
+
void this.flushSaves();
|
|
409
|
+
this.emit('pause', { owner });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
resume(owner = 'host'): void {
|
|
413
|
+
const Module = this.#module;
|
|
414
|
+
if (!Module || this.#pauseOwner !== owner) return;
|
|
415
|
+
this.#pauseOwner = null;
|
|
416
|
+
void Module.SDL2?.audioContext?.resume();
|
|
417
|
+
Module._web_set_paused(0);
|
|
418
|
+
this.emit('resume', { owner });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Ask FBNeo to write its NVRAM, then copy whatever changed in `/save` to OPFS
|
|
423
|
+
* `fbneo/<namespace>/`. Emits `saves` with the files that moved. Safe to call any
|
|
424
|
+
* time; overlapping calls share one flush.
|
|
425
|
+
*/
|
|
426
|
+
flushSaves(): Promise<void> {
|
|
427
|
+
this.#flushing ??= this.#flush().finally(() => {
|
|
428
|
+
this.#flushing = null;
|
|
429
|
+
});
|
|
430
|
+
return this.#flushing;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- Contract hooks ----
|
|
434
|
+
|
|
435
|
+
protected override mounted(target: HTMLElement): void {
|
|
436
|
+
if (target instanceof HTMLCanvasElement) {
|
|
437
|
+
this.#canvas = target;
|
|
438
|
+
this.#ownsCanvas = false;
|
|
439
|
+
this.#box = target.parentElement;
|
|
440
|
+
} else {
|
|
441
|
+
this.#canvas = document.createElement('canvas');
|
|
442
|
+
this.#ownsCanvas = true;
|
|
443
|
+
this.#box = target;
|
|
444
|
+
target.appendChild(this.#canvas);
|
|
185
445
|
}
|
|
186
|
-
|
|
446
|
+
const canvas = this.#canvas;
|
|
447
|
+
// Emscripten's SDL2 finds its window by id, and measures the element: no border or
|
|
448
|
+
// padding on the canvas itself, or SDL creates a tiny window.
|
|
449
|
+
canvas.id = 'canvas';
|
|
450
|
+
if (!canvas.width) canvas.width = FBNEO_VIDEO.width;
|
|
451
|
+
if (!canvas.height) canvas.height = FBNEO_VIDEO.height;
|
|
452
|
+
canvas.tabIndex = 0;
|
|
453
|
+
canvas.style.border = '0';
|
|
454
|
+
canvas.style.padding = '0';
|
|
455
|
+
canvas.style.display = 'block';
|
|
456
|
+
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
457
|
+
void this.#applyFilter();
|
|
187
458
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
459
|
+
if (this.#box && this.#ownsCanvas) {
|
|
460
|
+
this.#boxObserver?.disconnect();
|
|
461
|
+
this.#boxObserver = new ResizeObserver(() => this.#fit());
|
|
462
|
+
this.#boxObserver.observe(this.#box);
|
|
463
|
+
// FBNeo sizes the canvas to each game's picture (vertical games too): refit then.
|
|
464
|
+
this.#canvasObserver?.disconnect();
|
|
465
|
+
this.#canvasObserver = new MutationObserver(() => this.#fit());
|
|
466
|
+
this.#canvasObserver.observe(canvas, { attributes: true, attributeFilter: ['width', 'height'] });
|
|
467
|
+
this.#fit();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
protected override async open(_options: FbneoOptions): Promise<void> {
|
|
472
|
+
if (this.#destroyed) {
|
|
473
|
+
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');
|
|
474
|
+
}
|
|
475
|
+
if (this.#module) {
|
|
476
|
+
// Already running: a restart is the machine's soft reset.
|
|
477
|
+
if (this.#pauseOwner) this.resume(this.#pauseOwner);
|
|
478
|
+
this.#module._web_reset();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
await this.#boot();
|
|
482
|
+
}
|
|
196
483
|
|
|
197
|
-
|
|
484
|
+
protected override patched(kind: string, patch: object): void {
|
|
485
|
+
if (kind === 'config' && 'renderFilter' in patch) void this.#applyFilter();
|
|
486
|
+
}
|
|
198
487
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
488
|
+
protected override async close(): Promise<void> {
|
|
489
|
+
if (this.#saveTimer) clearInterval(this.#saveTimer);
|
|
490
|
+
this.#saveTimer = null;
|
|
491
|
+
document.removeEventListener('visibilitychange', this.#onHidden);
|
|
492
|
+
window.removeEventListener('pagehide', this.#onHidden);
|
|
203
493
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
494
|
+
const Module = this.#module;
|
|
495
|
+
if (Module) {
|
|
496
|
+
await this.flushSaves();
|
|
497
|
+
const context = Module.SDL2?.audioContext;
|
|
498
|
+
try {
|
|
499
|
+
Module._web_set_paused(1);
|
|
500
|
+
// Ends FBNeo's loop: it exits the game its own way (NVRAM written, sound closed).
|
|
501
|
+
Module._web_quit();
|
|
502
|
+
} catch {
|
|
503
|
+
/* already gone */
|
|
504
|
+
}
|
|
505
|
+
// FBNeo's exit closes the AudioContext on the loop's next turn; close it here only
|
|
506
|
+
// if it didn't (closing it first makes FBNeo's own close throw).
|
|
507
|
+
const deadline = performance.now() + 1000;
|
|
508
|
+
while (context && context.state !== 'closed' && performance.now() < deadline) {
|
|
509
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
510
|
+
}
|
|
511
|
+
if (context && context.state !== 'closed') await context.close().catch(() => {});
|
|
512
|
+
}
|
|
513
|
+
this.#boxObserver?.disconnect();
|
|
514
|
+
this.#boxObserver = null;
|
|
515
|
+
this.#canvasObserver?.disconnect();
|
|
516
|
+
this.#canvasObserver = null;
|
|
517
|
+
if (this.#ownsCanvas) this.#canvas?.remove();
|
|
518
|
+
this.#canvas = null;
|
|
519
|
+
this.#module = null;
|
|
520
|
+
this.#pauseOwner = null;
|
|
521
|
+
this.#destroyed = true;
|
|
213
522
|
}
|
|
214
523
|
|
|
215
|
-
|
|
216
|
-
readyResolve();
|
|
524
|
+
// ---- Boot ----
|
|
217
525
|
|
|
218
|
-
|
|
526
|
+
async #boot(): Promise<void> {
|
|
527
|
+
const config = await this.#values<FbneoConfig>('config');
|
|
528
|
+
const options = await this.#values<FbneoOptions>('options');
|
|
529
|
+
const assets = await this.#values<{ rom?: FbneoAsset; bios?: FbneoAsset }>('assets');
|
|
530
|
+
const storage = await this.#values<{ namespace?: string; persist?: 'opfs' | null }>('storage');
|
|
219
531
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
running = true;
|
|
224
|
-
// FBNeo SDL takes the ROM set short name and searches its rom paths for it.
|
|
225
|
-
modRef!.callMain([driver]);
|
|
226
|
-
emit({ type: 'ready' });
|
|
227
|
-
};
|
|
532
|
+
if (!this.#canvas) {
|
|
533
|
+
throw new Error('fbneo: mount() an element (or a canvas) before start()');
|
|
534
|
+
}
|
|
228
535
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
536
|
+
const driver = driverName(options);
|
|
537
|
+
if (!driver) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
'fbneo: which game is it? Pass options.driver (the FBNeo short name, e.g. "mslug") ' +
|
|
540
|
+
'or options.romFileName named after it (mslug.zip).',
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const rom = await resolveAsset(assets.rom);
|
|
544
|
+
if (!rom) throw new Error('fbneo: no ROM set — hand one over as assets.rom (bytes or a thunk)');
|
|
545
|
+
const bios = await resolveAsset(assets.bios);
|
|
546
|
+
this.#driver = driver;
|
|
232
547
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
548
|
+
const resolved = storagePath(ENGINE_ID, storage.namespace);
|
|
549
|
+
this.#namespace = resolved.namespace;
|
|
550
|
+
this.#storagePath = resolved.path;
|
|
551
|
+
this.#persist = storage.persist !== null;
|
|
552
|
+
|
|
553
|
+
const jsUrl = config.jsUrl ?? new URL('./fbneo.js', import.meta.url).href;
|
|
554
|
+
const wasmUrl = config.wasmUrl ?? new URL('./fbneo.wasm', import.meta.url).href;
|
|
555
|
+
const { default: createFbneoModule } = (await import(/* @vite-ignore */ jsUrl)) as {
|
|
556
|
+
default: FbneoFactory;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// The context SDL will use, made first so it keeps its picture between frames:
|
|
560
|
+
// FBNeo draws from timers, not animation frames, so without this snapshot() (the
|
|
561
|
+
// app's suspend-point frames) reads a cleared buffer. SDL gets this one back.
|
|
562
|
+
this.#canvas.getContext('webgl', { preserveDrawingBuffer: true, antialias: false });
|
|
563
|
+
|
|
564
|
+
let failed: ((error: Error) => void) | null = null;
|
|
565
|
+
// FBNeo reports a set it could not load only on stdout (then main() returns).
|
|
566
|
+
let refused: string | null = null;
|
|
567
|
+
const Module = await createFbneoModule({
|
|
568
|
+
canvas: this.#canvas,
|
|
569
|
+
noInitialRun: true,
|
|
570
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : new URL(path, jsUrl).href),
|
|
571
|
+
print: (text: string) => {
|
|
572
|
+
if (/error loading your selected game/i.test(text)) refused = text;
|
|
573
|
+
console.log('[fbneo]', text);
|
|
574
|
+
},
|
|
575
|
+
printErr: (text: string) => console.warn('[fbneo]', text),
|
|
576
|
+
onAbort: (what: unknown) => failed?.(new Error(`fbneo: aborted — ${String(what)}`)),
|
|
577
|
+
});
|
|
578
|
+
this.#module = Module;
|
|
579
|
+
failed = (error) => this.emit('error', error);
|
|
580
|
+
|
|
581
|
+
const { FS } = Module;
|
|
582
|
+
// FBNeo finds a game by `<driver>.zip` in its ROM paths, and matches the files
|
|
583
|
+
// inside by name/CRC: whatever the player named the file, it lands as that.
|
|
584
|
+
for (const dir of ROM_DIRS) {
|
|
585
|
+
FS.mkdirTree(dir);
|
|
586
|
+
FS.writeFile(`${dir}/${driver}.zip`, rom);
|
|
587
|
+
if (bios) FS.writeFile(`${dir}/neogeo.zip`, bios);
|
|
588
|
+
}
|
|
589
|
+
FS.mkdirTree(SAVE_DIR);
|
|
590
|
+
if (this.#persist) await this.#restoreSaves();
|
|
591
|
+
|
|
592
|
+
// callMain() returns at FBNeo's first yield, before the set is loaded: wait for
|
|
593
|
+
// the game to run, or for FBNeo to say it refused the set, so a bad set is an
|
|
594
|
+
// error here and not a black screen.
|
|
595
|
+
Module.callMain([driver]);
|
|
596
|
+
const deadline = performance.now() + LOAD_TIMEOUT_MS;
|
|
597
|
+
while (!Module._web_running() && !refused && performance.now() < deadline) {
|
|
598
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
599
|
+
}
|
|
600
|
+
if (!Module._web_running()) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
refused
|
|
603
|
+
? `fbneo: FBNeo could not load "${driver}" — the ROM set is incomplete, is not that driver, or needs a BIOS (neogeo.zip for Neo Geo)`
|
|
604
|
+
: `fbneo: "${driver}" did not start within ${LOAD_TIMEOUT_MS / 1000} s`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (options.neogeo === 'aes') await this.#bootAes(Module, bios);
|
|
609
|
+
|
|
610
|
+
if (this.#persist) {
|
|
611
|
+
this.#saveTimer = setInterval(() => void this.flushSaves(), SAVE_INTERVAL_MS);
|
|
612
|
+
document.addEventListener('visibilitychange', this.#onHidden);
|
|
613
|
+
window.addEventListener('pagehide', this.#onHidden);
|
|
614
|
+
}
|
|
615
|
+
this.#fit();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Neo Geo as the AES: pick the BIOS from the BIOS zip, set the driver's BIOS settings,
|
|
620
|
+
* insert the game's memory card and reset, so the machine boots that BIOS (FBNeo
|
|
621
|
+
* loads it at reset). A set that isn't Neo Geo has no "BIOS" setting: left as is.
|
|
622
|
+
*/
|
|
623
|
+
async #bootAes(Module: FbneoModule, bios: Uint8Array | null): Promise<void> {
|
|
624
|
+
let choice: NeoGeoBiosChoice | null = null;
|
|
625
|
+
try {
|
|
626
|
+
choice = bios ? neoGeoAesBios(parseZipEntries(bios)) : null;
|
|
627
|
+
} catch {
|
|
628
|
+
choice = null; // not a zip: no BIOS to choose from
|
|
629
|
+
}
|
|
630
|
+
const setDip = (group: string, option: string): boolean =>
|
|
631
|
+
Module.ccall('web_set_dip', 'number', ['string', 'string'], [group, option]) === 1;
|
|
632
|
+
if (!choice) {
|
|
633
|
+
// Only a Neo Geo driver has the setting; anything else plays as it always has.
|
|
634
|
+
if (!setDip('BIOS', 'AES World')) return;
|
|
635
|
+
throw new Error(
|
|
636
|
+
'fbneo: Neo Geo as the AES needs a BIOS zip (neogeo.zip) with the AES BIOS (neo-epo.bin) or Universe BIOS 4.0',
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
for (const [group, option] of choice.dips) {
|
|
640
|
+
if (!setDip(group, option)) {
|
|
641
|
+
if (group === 'BIOS') return; // not a Neo Geo set
|
|
642
|
+
throw new Error(`fbneo: this FBNeo build has no "${group}: ${option}" setting`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const card = Module._web_memcard_insert() === 1;
|
|
646
|
+
// The settings reach the driver on the next frame; the BIOS is loaded at reset.
|
|
647
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
648
|
+
Module._web_reset();
|
|
649
|
+
this.#neogeo = { mode: 'aes', bios: choice.bios, card };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The plain values behind one payload's descriptors. */
|
|
653
|
+
async #values<T>(kind: 'config' | 'options' | 'assets' | 'storage'): Promise<T> {
|
|
654
|
+
const described = (await this[kind]()) as Record<string, { value: unknown }>;
|
|
655
|
+
const out: Record<string, unknown> = {};
|
|
656
|
+
for (const [key, property] of Object.entries(described)) out[key] = property.value;
|
|
657
|
+
return out as T;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---- Saves ----
|
|
661
|
+
|
|
662
|
+
async #restoreSaves(): Promise<void> {
|
|
663
|
+
const dir = await opfsDir(this.#storagePath, false);
|
|
664
|
+
if (!dir || !this.#module) return;
|
|
665
|
+
const iterable = dir as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]> };
|
|
666
|
+
for await (const [name, handle] of iterable.entries()) {
|
|
667
|
+
if (handle.kind !== 'file') continue;
|
|
668
|
+
const bytes = new Uint8Array(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer());
|
|
669
|
+
this.#module.FS.writeFile(`${SAVE_DIR}/${name}`, bytes);
|
|
670
|
+
this.#lastSaved.set(name, bytes);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async #flush(): Promise<void> {
|
|
675
|
+
const Module = this.#module;
|
|
676
|
+
if (!Module || !this.#persist) return;
|
|
677
|
+
try {
|
|
678
|
+
Module._web_persist();
|
|
679
|
+
} catch (e) {
|
|
680
|
+
console.warn('[fbneo] persist failed', e);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const changed: Array<[string, Uint8Array<ArrayBuffer>]> = [];
|
|
684
|
+
for (const name of Module.FS.readdir(SAVE_DIR)) {
|
|
685
|
+
if (name === '.' || name === '..') continue;
|
|
686
|
+
const path = `${SAVE_DIR}/${name}`;
|
|
687
|
+
if (!Module.FS.isFile(Module.FS.stat(path).mode)) continue;
|
|
688
|
+
const bytes = new Uint8Array(Module.FS.readFile(path));
|
|
689
|
+
if (!sameBytes(this.#lastSaved.get(name), bytes)) changed.push([name, bytes]);
|
|
690
|
+
}
|
|
691
|
+
if (changed.length === 0) return;
|
|
692
|
+
|
|
693
|
+
const dir = await opfsDir(this.#storagePath, true);
|
|
694
|
+
if (!dir) return;
|
|
695
|
+
const written: string[] = [];
|
|
696
|
+
for (const [name, bytes] of changed) {
|
|
697
|
+
try {
|
|
698
|
+
const writable = await (await dir.getFileHandle(name, { create: true })).createWritable();
|
|
699
|
+
await writable.write(bytes);
|
|
700
|
+
await writable.close();
|
|
701
|
+
this.#lastSaved.set(name, bytes);
|
|
702
|
+
written.push(name);
|
|
703
|
+
} catch (e) {
|
|
704
|
+
console.warn(`[fbneo] could not persist ${name}`, e);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (written.length) this.emit('saves', { files: written });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ---- Picture ----
|
|
711
|
+
|
|
712
|
+
/** Scale the canvas to fit its box, keeping the picture's own proportions. */
|
|
713
|
+
#fit(): void {
|
|
714
|
+
const canvas = this.#canvas;
|
|
715
|
+
const box = this.#box;
|
|
716
|
+
if (!canvas || !box || !this.#ownsCanvas) return;
|
|
717
|
+
const { width, height } = box.getBoundingClientRect();
|
|
718
|
+
const w = canvas.width || FBNEO_VIDEO.width;
|
|
719
|
+
const h = canvas.height || FBNEO_VIDEO.height;
|
|
720
|
+
if (!width || !height) return;
|
|
721
|
+
const scale = Math.min(width / w, height / h);
|
|
722
|
+
canvas.style.width = `${Math.floor(w * scale)}px`;
|
|
723
|
+
canvas.style.height = `${Math.floor(h * scale)}px`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async #applyFilter(): Promise<void> {
|
|
727
|
+
const canvas = this.#canvas;
|
|
728
|
+
if (!canvas) return;
|
|
729
|
+
const { renderFilter } = await this.#values<FbneoConfig>('config');
|
|
730
|
+
canvas.style.imageRendering = renderFilter === 'smooth' ? 'auto' : 'pixelated';
|
|
731
|
+
}
|
|
254
732
|
}
|
|
255
733
|
|
|
256
|
-
|
|
734
|
+
/**
|
|
735
|
+
* The factory a host constructs: static identity, the configuration chain, and a
|
|
736
|
+
* `start()` that boots FBNeo and opens a session.
|
|
737
|
+
*/
|
|
738
|
+
export class FbneoSDK extends EngineSDKBase<FbneoPayloads> {
|
|
739
|
+
static readonly id = ENGINE_ID;
|
|
740
|
+
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
741
|
+
static readonly version = '0.3.0';
|
|
742
|
+
static readonly name = 'FinalBurn Neo';
|
|
743
|
+
static readonly description =
|
|
744
|
+
'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.';
|
|
745
|
+
|
|
746
|
+
protected override createPlay(
|
|
747
|
+
init: EngineInit<FbneoPayloads>,
|
|
748
|
+
events: EventEmitter<EventsOf<FbneoPayloads>>,
|
|
749
|
+
): FbneoPlay {
|
|
750
|
+
return new FbneoPlay(init, events);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** CI's conformance line: if this class stops satisfying the contract, `make test` fails. */
|
|
755
|
+
export const FBNEO_ENGINE: EngineSDKConstructor<FbneoPayloads> = FbneoSDK;
|
|
756
|
+
|
|
757
|
+
export default FbneoSDK;
|