@wasm-gaming/fbneo-wasm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/dist/fbneo/fbneo.js +2 -0
- package/dist/fbneo/fbneo.manifest.d.ts +4 -0
- package/dist/fbneo/fbneo.manifest.d.ts.map +1 -0
- package/dist/fbneo/fbneo.manifest.js +33 -0
- package/dist/fbneo/fbneo.manifest.js.map +1 -0
- package/dist/fbneo/fbneo.options.d.ts +27 -0
- package/dist/fbneo/fbneo.options.d.ts.map +1 -0
- package/dist/fbneo/fbneo.options.js +56 -0
- package/dist/fbneo/fbneo.options.js.map +1 -0
- package/dist/fbneo/fbneo.sdk.d.ts +10 -0
- package/dist/fbneo/fbneo.sdk.d.ts.map +1 -0
- package/dist/fbneo/fbneo.sdk.js +199 -0
- package/dist/fbneo/fbneo.sdk.js.map +1 -0
- package/dist/fbneo/fbneo.wasm +0 -0
- package/dist/manifest.json +91 -0
- package/package.json +43 -0
- package/src/fbneo.manifest.ts +38 -0
- package/src/fbneo.options.ts +83 -0
- package/src/fbneo.sdk.ts +256 -0
package/src/fbneo.sdk.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssetData,
|
|
3
|
+
EngineConfig,
|
|
4
|
+
EngineEvent,
|
|
5
|
+
EngineInstance,
|
|
6
|
+
InputPreset,
|
|
7
|
+
KeyMap,
|
|
8
|
+
} from '@wasm-gaming/engine-specs';
|
|
9
|
+
import { manifest } from './fbneo.manifest.js';
|
|
10
|
+
import { DEFAULT_FBNEO_OPTIONS, type FbneoOptions } from './fbneo.options.js';
|
|
11
|
+
|
|
12
|
+
export { manifest };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shape of the Emscripten Module object exposed by fbneo.js.
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
type FbneoModule = {
|
|
23
|
+
FS: {
|
|
24
|
+
mkdirTree(path: string): void;
|
|
25
|
+
writeFile(path: string, data: Uint8Array): void;
|
|
26
|
+
analyzePath(path: string): { exists: boolean };
|
|
27
|
+
readdir(path: string): string[];
|
|
28
|
+
};
|
|
29
|
+
callMain(args: string[]): void;
|
|
30
|
+
pauseMainLoop?: () => void;
|
|
31
|
+
resumeMainLoop?: () => void;
|
|
32
|
+
onRuntimeInitialized?: () => void;
|
|
33
|
+
locateFile?: (path: string, prefix: string) => string;
|
|
34
|
+
noInitialRun?: boolean;
|
|
35
|
+
canvas: HTMLCanvasElement;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type FbneoModuleFactory = (overrides: Partial<FbneoModule>) => Promise<FbneoModule>;
|
|
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;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toUint8(x: AssetData | undefined | unknown): Uint8Array | null {
|
|
60
|
+
if (x == null) return null;
|
|
61
|
+
if (typeof x === 'string') return new TextEncoder().encode(x);
|
|
62
|
+
if (x instanceof Uint8Array) return x;
|
|
63
|
+
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
64
|
+
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
65
|
+
throw new TypeError('fbneo: asset must be Uint8Array | ArrayBuffer | string');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Strip a directory and extension from a zip filename to get the FBNeo driver name. */
|
|
69
|
+
function driverFromFileName(fileName: string): string {
|
|
70
|
+
const base = fileName.replace(/^.*[\\/]/, '');
|
|
71
|
+
return base.replace(/\.(zip|7z)$/i, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the render canvas from the contract's `EngineConfig`.
|
|
76
|
+
* The contract is a union: hosts pass either `canvasEl` (a render target) or
|
|
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.
|
|
79
|
+
*/
|
|
80
|
+
function resolveCanvas(config: EngineConfig): HTMLCanvasElement {
|
|
81
|
+
const c =
|
|
82
|
+
(config as { canvasEl?: HTMLCanvasElement }).canvasEl ??
|
|
83
|
+
(config as { canvas?: HTMLCanvasElement }).canvas;
|
|
84
|
+
if (c) return c;
|
|
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;
|
|
93
|
+
}
|
|
94
|
+
throw new Error('fbneo: config.canvasEl or config.attachTo is required');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ensure the canvas used by FBNeo has `id="canvas"`.
|
|
99
|
+
* Emscripten SDL2 queries `document.querySelector('#canvas')` to locate its render target.
|
|
100
|
+
*/
|
|
101
|
+
function prepareCanvas(
|
|
102
|
+
canvas: HTMLCanvasElement,
|
|
103
|
+
renderFilter: Required<FbneoOptions>['renderFilter'],
|
|
104
|
+
): HTMLCanvasElement {
|
|
105
|
+
if (canvas.id !== 'canvas') canvas.id = 'canvas';
|
|
106
|
+
if (!canvas.width) canvas.width = manifest.video.baseWidth;
|
|
107
|
+
if (!canvas.height) canvas.height = manifest.video.baseHeight;
|
|
108
|
+
// SDL2's Emscripten backend measures getBoundingClientRect() (borders/padding
|
|
109
|
+
// included) to detect external CSS sizing; decoration on the canvas itself
|
|
110
|
+
// makes it create a tiny window. Host pages should decorate a wrapper instead.
|
|
111
|
+
canvas.style.border = '0';
|
|
112
|
+
canvas.style.padding = '0';
|
|
113
|
+
canvas.style.imageRendering = renderFilter === 'pixelated' ? 'pixelated' : 'auto';
|
|
114
|
+
return canvas;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function load(config: EngineConfig): Promise<EngineInstance> {
|
|
118
|
+
const { assets, onEvent } = config;
|
|
119
|
+
|
|
120
|
+
const emit = (e: EngineEvent): void => {
|
|
121
|
+
try {
|
|
122
|
+
onEvent?.(e);
|
|
123
|
+
} catch {
|
|
124
|
+
// host callback must not break the engine runtime
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Generic hosts (e.g. the engine-specs demo shell) pass the picked file's name
|
|
129
|
+
// as `options.fileName`; treat it as an alias for `romFileName` so the driver
|
|
130
|
+
// can be inferred from the ROM's filename without a FBNeo-specific option.
|
|
131
|
+
const rawOptions = config.options as
|
|
132
|
+
| (FbneoOptions & { fileName?: string })
|
|
133
|
+
| undefined;
|
|
134
|
+
const opts: Required<FbneoOptions> = {
|
|
135
|
+
...DEFAULT_FBNEO_OPTIONS,
|
|
136
|
+
...rawOptions,
|
|
137
|
+
romFileName:
|
|
138
|
+
rawOptions?.romFileName ??
|
|
139
|
+
rawOptions?.fileName ??
|
|
140
|
+
DEFAULT_FBNEO_OPTIONS.romFileName,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const romBytes = toUint8(assets?.rom ?? assets?.data);
|
|
144
|
+
if (!romBytes) {
|
|
145
|
+
throw new Error('fbneo: no ROM bytes provided — pass assets.rom (an arcade ROM zip)');
|
|
146
|
+
}
|
|
147
|
+
const biosBytes = toUint8(assets?.bios);
|
|
148
|
+
|
|
149
|
+
const canvas = prepareCanvas(resolveCanvas(config), opts.renderFilter);
|
|
150
|
+
|
|
151
|
+
// FBNeo boots a game by its ROM-set short name ("driver", e.g. "mslug") and
|
|
152
|
+
// searches its ROM paths for a matching `<driver>.zip`. Prefer an explicit
|
|
153
|
+
// `options.driver`; otherwise derive it from the ROM filename (stripping the
|
|
154
|
+
// extension). Whatever the source, the zip is mounted as `<driver>.zip` so the
|
|
155
|
+
// name FBNeo looks up and the name on disk always agree.
|
|
156
|
+
const driver = (opts.driver || driverFromFileName(opts.romFileName)).trim();
|
|
157
|
+
if (!driver || driver === 'game') {
|
|
158
|
+
throw new Error(
|
|
159
|
+
'fbneo: could not determine the ROM-set name. Pass options.driver (the FBNeo ' +
|
|
160
|
+
'short name, e.g. "mslug") or name the ROM file after it (e.g. mslug.zip).',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const romFileName = `${driver}.zip`;
|
|
164
|
+
|
|
165
|
+
const jsUrl = config.jsUrl ?? new URL('./fbneo.js', import.meta.url).href;
|
|
166
|
+
const wasmUrl = config.wasmUrl ?? new URL('./fbneo.wasm', jsUrl).href;
|
|
167
|
+
|
|
168
|
+
let modRef: FbneoModule | null = null;
|
|
169
|
+
let readyResolve: () => void = () => {};
|
|
170
|
+
const readyPromise = new Promise<void>((resolve) => {
|
|
171
|
+
readyResolve = resolve;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// FBNeo's default ROM search paths (src/burner/sdl/drv.cpp) are the absolute
|
|
175
|
+
// "/usr/local/share/roms/" and the cwd-relative "roms/". Under Emscripten the
|
|
176
|
+
// cwd is not guaranteed to be "/", so write to the absolute path (always
|
|
177
|
+
// searched) as well as "/roms/". FBNeo matches ROM files inside each zip by
|
|
178
|
+
// name/CRC, so the zip must be named after the driver.
|
|
179
|
+
const romDirs = ['/usr/local/share/roms', '/roms'];
|
|
180
|
+
const writeRoms = (mod: FbneoModule): void => {
|
|
181
|
+
for (const dir of romDirs) {
|
|
182
|
+
mod.FS.mkdirTree(dir);
|
|
183
|
+
mod.FS.writeFile(`${dir}/${romFileName}`, romBytes);
|
|
184
|
+
if (biosBytes) mod.FS.writeFile(`${dir}/neogeo.zip`, biosBytes);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const moduleOverrides: Partial<FbneoModule> = {
|
|
189
|
+
canvas,
|
|
190
|
+
noInitialRun: true,
|
|
191
|
+
locateFile(path: string): string {
|
|
192
|
+
if (path.endsWith('.wasm')) return wasmUrl;
|
|
193
|
+
return new URL(path, jsUrl).href;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
await loadClassicScriptOnce(jsUrl);
|
|
198
|
+
|
|
199
|
+
const g = globalThis as {
|
|
200
|
+
createFbneoModule?: FbneoModuleFactory;
|
|
201
|
+
Module?: FbneoModule;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Our build is a MODULARIZE factory (createFbneoModule). The factory promise
|
|
205
|
+
// resolves *after* the runtime is initialized and FS is available, so this is
|
|
206
|
+
// the correct point to populate MEMFS — write the ROMs before callMain().
|
|
207
|
+
if (typeof g.createFbneoModule === 'function') {
|
|
208
|
+
modRef = await g.createFbneoModule(moduleOverrides);
|
|
209
|
+
} else if (g.Module) {
|
|
210
|
+
modRef = g.Module;
|
|
211
|
+
} else {
|
|
212
|
+
throw new Error('fbneo: unable to initialize runtime module from fbneo.js');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
writeRoms(modRef);
|
|
216
|
+
readyResolve();
|
|
217
|
+
|
|
218
|
+
await readyPromise;
|
|
219
|
+
|
|
220
|
+
let running = false;
|
|
221
|
+
const boot = (): void => {
|
|
222
|
+
if (running) return;
|
|
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
|
+
};
|
|
228
|
+
|
|
229
|
+
const setInput = (_map: InputPreset | KeyMap): void => {
|
|
230
|
+
// Input is handled by the Emscripten SDL2 event loop; no external override yet.
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
start() {
|
|
235
|
+
boot();
|
|
236
|
+
},
|
|
237
|
+
pause() {
|
|
238
|
+
modRef?.pauseMainLoop?.();
|
|
239
|
+
},
|
|
240
|
+
resume() {
|
|
241
|
+
modRef?.resumeMainLoop?.();
|
|
242
|
+
},
|
|
243
|
+
reset() {
|
|
244
|
+
// FBNeo does not expose a soft-reset hook through this WASM build.
|
|
245
|
+
throw new Error('fbneo: reset is not supported by this build');
|
|
246
|
+
},
|
|
247
|
+
setInput,
|
|
248
|
+
destroy() {
|
|
249
|
+
modRef?.pauseMainLoop?.();
|
|
250
|
+
running = false;
|
|
251
|
+
emit({ type: 'exit' });
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default { manifest, load };
|