game-koi 0.5.0 → 0.7.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 +34 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +138 -6
- package/dist/saves.d.ts +56 -0
- package/dist/saves.js +121 -0
- package/package.json +1 -1
- package/wasm/game_koi_web.d.ts +50 -0
- package/wasm/game_koi_web.js +87 -0
- package/wasm/game_koi_web_bg.wasm +0 -0
- package/wasm/game_koi_web_bg.wasm.d.ts +6 -0
package/README.md
CHANGED
|
@@ -77,6 +77,32 @@ Two cases release without anyone letting go: `loadRom()` releases everything (as
|
|
|
77
77
|
the gamepad was holding (as `"gamepad"`), since polling stops and nothing else would.
|
|
78
78
|
Keys keep their own `keyup` while paused. `dispose()` drops every listener.
|
|
79
79
|
|
|
80
|
+
### Saves
|
|
81
|
+
|
|
82
|
+
Games that save — the ones whose cartridge has a battery — keep their saves in
|
|
83
|
+
`localStorage` automatically, so a reload or a later visit picks up where the player
|
|
84
|
+
left off. Nothing needs configuring. The save is written about every two seconds while
|
|
85
|
+
the game has unsaved changes, and again on `pause()`, `loadRom()`, `dispose()`, and when
|
|
86
|
+
the tab is hidden or closed. Each write, and each save restored when a ROM loads, is
|
|
87
|
+
logged with `console.info`, so the browser console shows when a game saved and under
|
|
88
|
+
which key.
|
|
89
|
+
|
|
90
|
+
A few things worth knowing:
|
|
91
|
+
|
|
92
|
+
- **Only battery-backed cartridges are saved**, as on hardware: RAM on a board without
|
|
93
|
+
a battery is gone when the power is. Check with `koi.exportSave() !== null`.
|
|
94
|
+
- Saves are keyed by the ROM's header title plus a CRC32 of the whole ROM, so two
|
|
95
|
+
ROMs sharing a title (a hack and its original, say) do not share a save.
|
|
96
|
+
- `localStorage` is per origin and holds roughly 5 MiB. The largest Game Boy save is
|
|
97
|
+
128 KiB (about 171 KiB stored), so that is plenty for a handful of games but not an
|
|
98
|
+
unlimited library. A write that hits the quota is logged and retried later, not lost.
|
|
99
|
+
- `koi.exportSave()` / `koi.importSave(bytes)` read and replace the raw save RAM — the
|
|
100
|
+
same `.sav` format the desktop build and most other emulators use — for download or
|
|
101
|
+
upload buttons. A game only re-reads its save at its own pace, usually on the title
|
|
102
|
+
screen, so call `loadRom()` after an import if you want it picked up at once.
|
|
103
|
+
- Pass `saves: false` to `create()` to leave `localStorage` alone and handle saves
|
|
104
|
+
entirely through `exportSave`/`importSave` — for IndexedDB, or a server.
|
|
105
|
+
|
|
80
106
|
### Debug overlay
|
|
81
107
|
|
|
82
108
|
Press <kbd>`</kbd> (backtick) to toggle a stats panel over the canvas, or call
|
|
@@ -114,13 +140,21 @@ element. Pass `overlayKey: null` to bind no key.
|
|
|
114
140
|
- `targetBuffer?: number` — audio samples to keep queued ahead. Default `1600`.
|
|
115
141
|
- `maxFramesPerWake?: number` — cap on frames emulated per wake-up, so a
|
|
116
142
|
backgrounded tab can't return to a freeze. Default `4`.
|
|
143
|
+
- `saves?: boolean` — keep battery-backed saves in `localStorage`. Default `true`.
|
|
117
144
|
- `koi.loadRom(rom)` — swaps in a new ROM, keeping the same canvas and audio setup.
|
|
145
|
+
- `koi.exportSave()` — the cartridge's save RAM as a `Uint8Array`, or `null` if it
|
|
146
|
+
has no battery-backed RAM.
|
|
147
|
+
- `koi.importSave(bytes)` — replaces the save RAM (and stores it, if saving is on).
|
|
148
|
+
Returns `false` if the cartridge has none or `bytes` is shorter than it.
|
|
118
149
|
- `koi.press(button)` / `koi.release(button)` — `Button` is one of `"up"`, `"down"`,
|
|
119
150
|
`"left"`, `"right"`, `"a"`, `"b"`, `"start"`, `"select"`.
|
|
120
151
|
- `koi.on(type, listener)` / `koi.off(type, listener)` — `"press"` and `"release"`
|
|
121
152
|
button edges, from any input path. `on` returns a function that unsubscribes. The
|
|
122
153
|
payload is `{ button, source: "keyboard" | "gamepad" | "api", pressed: Set<Button> }`.
|
|
123
154
|
- `koi.pressed` — everything held right now, as a `Set<Button>` snapshot.
|
|
155
|
+
- `koi.version` — the emulator's version, e.g. `"0.6.0"`. It is also logged once per
|
|
156
|
+
page (`console.info`) when the wasm module loads. It is read from the wasm binary
|
|
157
|
+
itself, so a stale cached `.wasm` shows up as a mismatch here.
|
|
124
158
|
- `koi.toggleOverlay()` / `koi.overlayVisible` — the debug stats panel.
|
|
125
159
|
- `koi.pause()` / `koi.resume()`.
|
|
126
160
|
- `koi.dispose()` — stops the loop and tears down the audio graph. Call this before
|
package/dist/index.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface GameKoiOptions {
|
|
|
42
42
|
* freeze the page catching up. Default 4.
|
|
43
43
|
*/
|
|
44
44
|
maxFramesPerWake?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Keep battery-backed save RAM in `localStorage`, so a game's saves survive a
|
|
47
|
+
* reload. Default `true`. Cartridges without a battery are never saved, as on
|
|
48
|
+
* hardware. Pass `false` to manage saves yourself with {@link GameKoi.exportSave}
|
|
49
|
+
* and {@link GameKoi.importSave}.
|
|
50
|
+
*/
|
|
51
|
+
saves?: boolean;
|
|
45
52
|
}
|
|
46
53
|
/** A running Game Boy, attached to a canvas and driving an AudioWorklet. */
|
|
47
54
|
export declare class GameKoi {
|
|
@@ -70,6 +77,12 @@ export declare class GameKoi {
|
|
|
70
77
|
private keydownListener?;
|
|
71
78
|
private keyupListener?;
|
|
72
79
|
private overlayListener?;
|
|
80
|
+
/** Where saves go, or `null` if saving is off or storage is unavailable. */
|
|
81
|
+
private readonly saves;
|
|
82
|
+
/** The current ROM's storage key. */
|
|
83
|
+
private saveKey;
|
|
84
|
+
private framesSinceAutosave;
|
|
85
|
+
private flushListener?;
|
|
73
86
|
private constructor();
|
|
74
87
|
/**
|
|
75
88
|
* Builds and starts a running emulator.
|
|
@@ -80,8 +93,26 @@ export declare class GameKoi {
|
|
|
80
93
|
static create(options: GameKoiOptions): Promise<GameKoi>;
|
|
81
94
|
/** Swaps in a new ROM, keeping the same canvas and audio graph. */
|
|
82
95
|
loadRom(rom: Uint8Array): void;
|
|
96
|
+
/**
|
|
97
|
+
* The cartridge's save RAM, or `null` if it has no battery-backed RAM.
|
|
98
|
+
*
|
|
99
|
+
* The same raw bytes the desktop build writes to its `.sav` file, so either can be
|
|
100
|
+
* handed to the other — and to most other emulators, which use the same format.
|
|
101
|
+
*/
|
|
102
|
+
exportSave(): Uint8Array | null;
|
|
103
|
+
/**
|
|
104
|
+
* Replaces the cartridge's save RAM, and stores it if saving is on.
|
|
105
|
+
*
|
|
106
|
+
* Returns `false` if the cartridge has no battery-backed RAM or the data is shorter
|
|
107
|
+
* than its RAM (more likely another game's save than a truncated one of this game's).
|
|
108
|
+
* The running game will not notice until it next reads its save — for most games,
|
|
109
|
+
* that means going back to the title screen or calling {@link loadRom} again.
|
|
110
|
+
*/
|
|
111
|
+
importSave(data: Uint8Array): boolean;
|
|
83
112
|
press(button: Button): void;
|
|
84
113
|
release(button: Button): void;
|
|
114
|
+
/** The version of the emulator core running — the same number logged at startup. */
|
|
115
|
+
get version(): string;
|
|
85
116
|
/** Everything the joypad has down right now — a snapshot, safe to keep. */
|
|
86
117
|
get pressed(): ReadonlySet<Button>;
|
|
87
118
|
/**
|
|
@@ -143,6 +174,26 @@ export declare class GameKoi {
|
|
|
143
174
|
*/
|
|
144
175
|
private pollGamepads;
|
|
145
176
|
private applyGamepadActions;
|
|
177
|
+
/** Loads the stored save for `rom` into the machine just built for it. */
|
|
178
|
+
private restoreSave;
|
|
179
|
+
/**
|
|
180
|
+
* Writes the save if the game has touched its RAM since the last write.
|
|
181
|
+
*
|
|
182
|
+
* The dirty flag is cleared only after the write succeeds, so a full quota or
|
|
183
|
+
* blocked storage leaves the save pending for the next attempt rather than
|
|
184
|
+
* silently dropped.
|
|
185
|
+
*/
|
|
186
|
+
private flushSave;
|
|
187
|
+
/**
|
|
188
|
+
* Flushes when the page goes away.
|
|
189
|
+
*
|
|
190
|
+
* `pagehide` and a hidden `visibilitychange` rather than `beforeunload`, which
|
|
191
|
+
* mobile browsers skip when they kill a backgrounded tab. Hidden is the last moment
|
|
192
|
+
* a page is reliably given, and `localStorage` being synchronous is what makes
|
|
193
|
+
* writing in it safe — an async store could be cut off mid-write.
|
|
194
|
+
*/
|
|
195
|
+
private attachSaveFlush;
|
|
196
|
+
private detachSaveFlush;
|
|
146
197
|
private attachKeyboard;
|
|
147
198
|
private detachKeyboard;
|
|
148
199
|
/**
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
* ships as a normal ES module asset next to this file, and the audio worklet is
|
|
8
8
|
* inlined as a Blob URL.
|
|
9
9
|
*/
|
|
10
|
-
import init, { Emulator } from "../wasm/game_koi_web.js";
|
|
10
|
+
import init, { Emulator, version as wasmVersion } from "../wasm/game_koi_web.js";
|
|
11
11
|
import { WORKLET_SOURCE } from "./worklet.js";
|
|
12
12
|
import { DEFAULT_GAMEPAD_MAP, GamepadInput } from "./gamepad.js";
|
|
13
13
|
import { Emitter } from "./events.js";
|
|
14
14
|
import { FrameStats } from "./stats.js";
|
|
15
15
|
import { StatsOverlay } from "./overlay.js";
|
|
16
|
+
import { LocalStorageSaves, defaultStorage, saveKey } from "./saves.js";
|
|
17
|
+
/**
|
|
18
|
+
* How often the loop checks for unsaved RAM, in emulated frames — about two seconds,
|
|
19
|
+
* the desktop's interval. Most checks find nothing: a game only writes save RAM when
|
|
20
|
+
* it saves.
|
|
21
|
+
*/
|
|
22
|
+
const AUTOSAVE_FRAMES = 120;
|
|
16
23
|
const DEFAULT_KEYMAP = {
|
|
17
24
|
ArrowRight: "right",
|
|
18
25
|
ArrowLeft: "left",
|
|
@@ -24,11 +31,16 @@ const DEFAULT_KEYMAP = {
|
|
|
24
31
|
ShiftRight: "select",
|
|
25
32
|
};
|
|
26
33
|
// The wasm module only needs instantiating once per page, no matter how many
|
|
27
|
-
// `GameKoi` instances are created.
|
|
34
|
+
// `GameKoi` instances are created. The version line is logged here for the same
|
|
35
|
+
// reason: once per page, not once per instance or per `loadRom`.
|
|
28
36
|
let wasmReady = null;
|
|
29
37
|
function ensureWasm() {
|
|
30
|
-
if (!wasmReady)
|
|
31
|
-
wasmReady = init()
|
|
38
|
+
if (!wasmReady) {
|
|
39
|
+
wasmReady = init().then((wasm) => {
|
|
40
|
+
console.info(`game-koi ${wasmVersion()}`);
|
|
41
|
+
return wasm;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
32
44
|
return wasmReady;
|
|
33
45
|
}
|
|
34
46
|
/** A running Game Boy, attached to a canvas and driving an AudioWorklet. */
|
|
@@ -58,7 +70,13 @@ export class GameKoi {
|
|
|
58
70
|
keydownListener;
|
|
59
71
|
keyupListener;
|
|
60
72
|
overlayListener;
|
|
61
|
-
|
|
73
|
+
/** Where saves go, or `null` if saving is off or storage is unavailable. */
|
|
74
|
+
saves;
|
|
75
|
+
/** The current ROM's storage key. */
|
|
76
|
+
saveKey = "";
|
|
77
|
+
framesSinceAutosave = 0;
|
|
78
|
+
flushListener;
|
|
79
|
+
constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake, saves) {
|
|
62
80
|
this.emulator = emulator;
|
|
63
81
|
this.wasmMemory = wasmMemory;
|
|
64
82
|
this.ctx = ctx;
|
|
@@ -70,6 +88,7 @@ export class GameKoi {
|
|
|
70
88
|
this.maxFramesPerWake = maxFramesPerWake;
|
|
71
89
|
this.imageData = ctx.createImageData(Emulator.width(), Emulator.height());
|
|
72
90
|
this.stats = new FrameStats(performance.now());
|
|
91
|
+
this.saves = saves;
|
|
73
92
|
this.worklet.port.onmessage = (event) => {
|
|
74
93
|
this.buffered = event.data.buffered;
|
|
75
94
|
this.underruns = event.data.underruns;
|
|
@@ -79,6 +98,8 @@ export class GameKoi {
|
|
|
79
98
|
this.attachKeyboard();
|
|
80
99
|
if (overlayKey !== null)
|
|
81
100
|
this.attachOverlayKey(overlayKey);
|
|
101
|
+
if (saves)
|
|
102
|
+
this.attachSaveFlush();
|
|
82
103
|
}
|
|
83
104
|
/**
|
|
84
105
|
* Builds and starts a running emulator.
|
|
@@ -109,7 +130,9 @@ export class GameKoi {
|
|
|
109
130
|
const emulator = new Emulator(options.rom, audioContext.sampleRate);
|
|
110
131
|
const keymap = options.keymap === null ? null : options.keymap ?? DEFAULT_KEYMAP;
|
|
111
132
|
const gamepadMap = options.gamepadMap === null ? null : options.gamepadMap ?? DEFAULT_GAMEPAD_MAP;
|
|
112
|
-
const
|
|
133
|
+
const storage = options.saves === false ? null : defaultStorage();
|
|
134
|
+
const koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, gamepadMap, options.overlayKey === null ? null : options.overlayKey ?? "Backquote", options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4, storage ? new LocalStorageSaves(storage) : null);
|
|
135
|
+
koi.restoreSave(options.rom);
|
|
113
136
|
koi.running = true;
|
|
114
137
|
koi.loop();
|
|
115
138
|
return koi;
|
|
@@ -123,8 +146,40 @@ export class GameKoi {
|
|
|
123
146
|
// machine, and a display would show it stuck down).
|
|
124
147
|
this.gamepad?.releaseAll();
|
|
125
148
|
this.releaseAll();
|
|
149
|
+
// Before the old machine is freed, or whatever it saved since the last autosave
|
|
150
|
+
// goes with it.
|
|
151
|
+
this.flushSave();
|
|
126
152
|
this.emulator.free();
|
|
127
153
|
this.emulator = new Emulator(rom, this.audioContext.sampleRate);
|
|
154
|
+
this.restoreSave(rom);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The cartridge's save RAM, or `null` if it has no battery-backed RAM.
|
|
158
|
+
*
|
|
159
|
+
* The same raw bytes the desktop build writes to its `.sav` file, so either can be
|
|
160
|
+
* handed to the other — and to most other emulators, which use the same format.
|
|
161
|
+
*/
|
|
162
|
+
exportSave() {
|
|
163
|
+
return this.emulator.save_data() ?? null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Replaces the cartridge's save RAM, and stores it if saving is on.
|
|
167
|
+
*
|
|
168
|
+
* Returns `false` if the cartridge has no battery-backed RAM or the data is shorter
|
|
169
|
+
* than its RAM (more likely another game's save than a truncated one of this game's).
|
|
170
|
+
* The running game will not notice until it next reads its save — for most games,
|
|
171
|
+
* that means going back to the title screen or calling {@link loadRom} again.
|
|
172
|
+
*/
|
|
173
|
+
importSave(data) {
|
|
174
|
+
if (!this.emulator.load_save(data))
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
this.saves?.store(this.saveKey, data);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.warn("game-koi: could not store imported save", error);
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
128
183
|
}
|
|
129
184
|
press(button) {
|
|
130
185
|
this.setButton(button, true, "api");
|
|
@@ -132,6 +187,10 @@ export class GameKoi {
|
|
|
132
187
|
release(button) {
|
|
133
188
|
this.setButton(button, false, "api");
|
|
134
189
|
}
|
|
190
|
+
/** The version of the emulator core running — the same number logged at startup. */
|
|
191
|
+
get version() {
|
|
192
|
+
return wasmVersion();
|
|
193
|
+
}
|
|
135
194
|
/** Everything the joypad has down right now — a snapshot, safe to keep. */
|
|
136
195
|
get pressed() {
|
|
137
196
|
return new Set(this.held);
|
|
@@ -202,6 +261,7 @@ export class GameKoi {
|
|
|
202
261
|
return this.overlay?.visible ?? false;
|
|
203
262
|
}
|
|
204
263
|
pause() {
|
|
264
|
+
this.flushSave();
|
|
205
265
|
this.running = false;
|
|
206
266
|
cancelAnimationFrame(this.rafHandle);
|
|
207
267
|
// A direction held at the moment of the pause has no poll coming to release it,
|
|
@@ -220,6 +280,8 @@ export class GameKoi {
|
|
|
220
280
|
dispose() {
|
|
221
281
|
this.running = false;
|
|
222
282
|
cancelAnimationFrame(this.rafHandle);
|
|
283
|
+
this.flushSave();
|
|
284
|
+
this.detachSaveFlush();
|
|
223
285
|
this.detachKeyboard();
|
|
224
286
|
if (this.overlayListener)
|
|
225
287
|
removeEventListener("keydown", this.overlayListener);
|
|
@@ -258,6 +320,11 @@ export class GameKoi {
|
|
|
258
320
|
}
|
|
259
321
|
frames++;
|
|
260
322
|
}
|
|
323
|
+
this.framesSinceAutosave += frames;
|
|
324
|
+
if (this.framesSinceAutosave >= AUTOSAVE_FRAMES) {
|
|
325
|
+
this.framesSinceAutosave = 0;
|
|
326
|
+
this.flushSave();
|
|
327
|
+
}
|
|
261
328
|
const drawStart = performance.now();
|
|
262
329
|
if (frames > 0)
|
|
263
330
|
this.drawFrame();
|
|
@@ -321,6 +388,71 @@ export class GameKoi {
|
|
|
321
388
|
this.setButton(action.button, action.type === "press", "gamepad");
|
|
322
389
|
}
|
|
323
390
|
}
|
|
391
|
+
/** Loads the stored save for `rom` into the machine just built for it. */
|
|
392
|
+
restoreSave(rom) {
|
|
393
|
+
this.framesSinceAutosave = 0;
|
|
394
|
+
if (!this.saves)
|
|
395
|
+
return;
|
|
396
|
+
this.saveKey = saveKey(rom);
|
|
397
|
+
if (!this.emulator.has_save())
|
|
398
|
+
return;
|
|
399
|
+
const data = this.saves.load(this.saveKey);
|
|
400
|
+
// No stored save is the normal first run, and not worth a line.
|
|
401
|
+
if (!data)
|
|
402
|
+
return;
|
|
403
|
+
const where = `localStorage["${this.saveKey}"]`;
|
|
404
|
+
if (this.emulator.load_save(data)) {
|
|
405
|
+
console.info(`game-koi: restored ${data.length} bytes from ${where}`);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
console.warn(`game-koi: ignoring ${where}, it is smaller than this cartridge's RAM`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Writes the save if the game has touched its RAM since the last write.
|
|
413
|
+
*
|
|
414
|
+
* The dirty flag is cleared only after the write succeeds, so a full quota or
|
|
415
|
+
* blocked storage leaves the save pending for the next attempt rather than
|
|
416
|
+
* silently dropped.
|
|
417
|
+
*/
|
|
418
|
+
flushSave() {
|
|
419
|
+
if (!this.saves || !this.emulator.save_dirty())
|
|
420
|
+
return;
|
|
421
|
+
const data = this.emulator.save_data();
|
|
422
|
+
if (!data)
|
|
423
|
+
return;
|
|
424
|
+
try {
|
|
425
|
+
this.saves.store(this.saveKey, data);
|
|
426
|
+
this.emulator.mark_saved();
|
|
427
|
+
// Only reached when the game wrote its save RAM, so this is one line per in-game
|
|
428
|
+
// save (give or take the ~2 s autosave batching), not one per autosave tick.
|
|
429
|
+
console.info(`game-koi: saved ${data.length} bytes to localStorage["${this.saveKey}"]`);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
console.warn("game-koi: could not store save", error);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Flushes when the page goes away.
|
|
437
|
+
*
|
|
438
|
+
* `pagehide` and a hidden `visibilitychange` rather than `beforeunload`, which
|
|
439
|
+
* mobile browsers skip when they kill a backgrounded tab. Hidden is the last moment
|
|
440
|
+
* a page is reliably given, and `localStorage` being synchronous is what makes
|
|
441
|
+
* writing in it safe — an async store could be cut off mid-write.
|
|
442
|
+
*/
|
|
443
|
+
attachSaveFlush() {
|
|
444
|
+
// Unconditional, since a flush with nothing dirty is a no-op: becoming *visible*
|
|
445
|
+
// costs one check, and `pagehide` need not trust the visibility state to be set yet.
|
|
446
|
+
this.flushListener = () => this.flushSave();
|
|
447
|
+
addEventListener("pagehide", this.flushListener);
|
|
448
|
+
document.addEventListener("visibilitychange", this.flushListener);
|
|
449
|
+
}
|
|
450
|
+
detachSaveFlush() {
|
|
451
|
+
if (!this.flushListener)
|
|
452
|
+
return;
|
|
453
|
+
removeEventListener("pagehide", this.flushListener);
|
|
454
|
+
document.removeEventListener("visibilitychange", this.flushListener);
|
|
455
|
+
}
|
|
324
456
|
attachKeyboard() {
|
|
325
457
|
this.keydownListener = (event) => {
|
|
326
458
|
const button = this.keymap?.[event.code];
|
package/dist/saves.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Battery-backed saves, kept in `localStorage`.
|
|
3
|
+
*
|
|
4
|
+
* The browser counterpart of the desktop's `save.rs`. The rule for *what* is saved
|
|
5
|
+
* lives on the Rust side (`Emulator::has_save`: a battery and RAM to keep powered);
|
|
6
|
+
* this module only decides where the bytes go and what they are called.
|
|
7
|
+
*
|
|
8
|
+
* # The key
|
|
9
|
+
*
|
|
10
|
+
* The desktop names a save after the ROM's filename, but a page hands over bytes, not
|
|
11
|
+
* a file. The header title is not enough on its own — plenty of cartridges share one,
|
|
12
|
+
* and a ROM hack keeps its original's — so the key is the title plus a CRC32 of the
|
|
13
|
+
* whole ROM, the same checksum No-Intro uses to identify dumps. The title is only
|
|
14
|
+
* there so a person reading their storage in devtools can tell what is what.
|
|
15
|
+
*
|
|
16
|
+
* # The encoding
|
|
17
|
+
*
|
|
18
|
+
* `localStorage` holds strings, so the RAM is stored as base64. The largest DMG save
|
|
19
|
+
* is 128 KiB (MBC5), about 171 KiB encoded, against a per-origin quota of roughly
|
|
20
|
+
* 5 MiB — ample for a handful of games, not for a library of the largest ones. A write
|
|
21
|
+
* that hits the quota throws; the caller leaves the save dirty and tries again later.
|
|
22
|
+
*
|
|
23
|
+
* Everything but {@link LocalStorageSaves} is pure, and that class takes its `Storage`
|
|
24
|
+
* as a parameter, so all of it is testable under Node without a DOM.
|
|
25
|
+
*/
|
|
26
|
+
/** CRC-32 (IEEE 802.3, as zlib and No-Intro use), over the whole input. */
|
|
27
|
+
export declare function crc32(bytes: Uint8Array): number;
|
|
28
|
+
/**
|
|
29
|
+
* The cartridge title from the header, 0x134–0x143.
|
|
30
|
+
*
|
|
31
|
+
* Read up to the first NUL, printable ASCII only. On later cartridges the last bytes of
|
|
32
|
+
* that range were repurposed (manufacturer code, CGB flag), which is why stray
|
|
33
|
+
* non-letters get dropped rather than trusted.
|
|
34
|
+
*/
|
|
35
|
+
export declare function romTitle(rom: Uint8Array): string;
|
|
36
|
+
/** The storage key a ROM's save lives under. */
|
|
37
|
+
export declare function saveKey(rom: Uint8Array): string;
|
|
38
|
+
export declare function encodeSave(bytes: Uint8Array): string;
|
|
39
|
+
/** Decodes a stored save, or `null` if the string is not valid base64. */
|
|
40
|
+
export declare function decodeSave(text: string): Uint8Array | null;
|
|
41
|
+
/** Reads and writes saves in a `Storage` — `localStorage` in a page. */
|
|
42
|
+
export declare class LocalStorageSaves {
|
|
43
|
+
private readonly storage;
|
|
44
|
+
constructor(storage: Storage);
|
|
45
|
+
load(key: string): Uint8Array | null;
|
|
46
|
+
/** Throws if the write fails — most often `QuotaExceededError`. */
|
|
47
|
+
store(key: string, bytes: Uint8Array): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `localStorage`, or `null` where touching it throws.
|
|
51
|
+
*
|
|
52
|
+
* It is not just absent in odd places: with storage blocked by the user, or in a
|
|
53
|
+
* sandboxed iframe, merely *reading the property* throws a `SecurityError`. A page
|
|
54
|
+
* that cannot save should still play.
|
|
55
|
+
*/
|
|
56
|
+
export declare function defaultStorage(): Storage | null;
|
package/dist/saves.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Battery-backed saves, kept in `localStorage`.
|
|
3
|
+
*
|
|
4
|
+
* The browser counterpart of the desktop's `save.rs`. The rule for *what* is saved
|
|
5
|
+
* lives on the Rust side (`Emulator::has_save`: a battery and RAM to keep powered);
|
|
6
|
+
* this module only decides where the bytes go and what they are called.
|
|
7
|
+
*
|
|
8
|
+
* # The key
|
|
9
|
+
*
|
|
10
|
+
* The desktop names a save after the ROM's filename, but a page hands over bytes, not
|
|
11
|
+
* a file. The header title is not enough on its own — plenty of cartridges share one,
|
|
12
|
+
* and a ROM hack keeps its original's — so the key is the title plus a CRC32 of the
|
|
13
|
+
* whole ROM, the same checksum No-Intro uses to identify dumps. The title is only
|
|
14
|
+
* there so a person reading their storage in devtools can tell what is what.
|
|
15
|
+
*
|
|
16
|
+
* # The encoding
|
|
17
|
+
*
|
|
18
|
+
* `localStorage` holds strings, so the RAM is stored as base64. The largest DMG save
|
|
19
|
+
* is 128 KiB (MBC5), about 171 KiB encoded, against a per-origin quota of roughly
|
|
20
|
+
* 5 MiB — ample for a handful of games, not for a library of the largest ones. A write
|
|
21
|
+
* that hits the quota throws; the caller leaves the save dirty and tries again later.
|
|
22
|
+
*
|
|
23
|
+
* Everything but {@link LocalStorageSaves} is pure, and that class takes its `Storage`
|
|
24
|
+
* as a parameter, so all of it is testable under Node without a DOM.
|
|
25
|
+
*/
|
|
26
|
+
const KEY_PREFIX = "game-koi:save:";
|
|
27
|
+
let crcTable = null;
|
|
28
|
+
/** CRC-32 (IEEE 802.3, as zlib and No-Intro use), over the whole input. */
|
|
29
|
+
export function crc32(bytes) {
|
|
30
|
+
if (!crcTable) {
|
|
31
|
+
crcTable = new Uint32Array(256);
|
|
32
|
+
for (let n = 0; n < 256; n++) {
|
|
33
|
+
let c = n;
|
|
34
|
+
for (let k = 0; k < 8; k++)
|
|
35
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
36
|
+
crcTable[n] = c >>> 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let crc = 0xffffffff;
|
|
40
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
41
|
+
crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
42
|
+
}
|
|
43
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The cartridge title from the header, 0x134–0x143.
|
|
47
|
+
*
|
|
48
|
+
* Read up to the first NUL, printable ASCII only. On later cartridges the last bytes of
|
|
49
|
+
* that range were repurposed (manufacturer code, CGB flag), which is why stray
|
|
50
|
+
* non-letters get dropped rather than trusted.
|
|
51
|
+
*/
|
|
52
|
+
export function romTitle(rom) {
|
|
53
|
+
let title = "";
|
|
54
|
+
for (let i = 0x134; i < 0x144 && i < rom.length; i++) {
|
|
55
|
+
const byte = rom[i];
|
|
56
|
+
if (byte === 0)
|
|
57
|
+
break;
|
|
58
|
+
if (byte >= 0x20 && byte < 0x7f)
|
|
59
|
+
title += String.fromCharCode(byte);
|
|
60
|
+
}
|
|
61
|
+
return title.trim();
|
|
62
|
+
}
|
|
63
|
+
/** The storage key a ROM's save lives under. */
|
|
64
|
+
export function saveKey(rom) {
|
|
65
|
+
const crc = crc32(rom).toString(16).padStart(8, "0");
|
|
66
|
+
return `${KEY_PREFIX}${romTitle(rom) || "untitled"}:${crc}`;
|
|
67
|
+
}
|
|
68
|
+
export function encodeSave(bytes) {
|
|
69
|
+
// Chunked because `String.fromCharCode(...bytes)` on 128 KiB overflows the argument
|
|
70
|
+
// limit of some engines' call stacks.
|
|
71
|
+
let binary = "";
|
|
72
|
+
const chunk = 0x8000;
|
|
73
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
74
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
75
|
+
}
|
|
76
|
+
return btoa(binary);
|
|
77
|
+
}
|
|
78
|
+
/** Decodes a stored save, or `null` if the string is not valid base64. */
|
|
79
|
+
export function decodeSave(text) {
|
|
80
|
+
let binary;
|
|
81
|
+
try {
|
|
82
|
+
binary = atob(text);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const bytes = new Uint8Array(binary.length);
|
|
88
|
+
for (let i = 0; i < binary.length; i++)
|
|
89
|
+
bytes[i] = binary.charCodeAt(i);
|
|
90
|
+
return bytes;
|
|
91
|
+
}
|
|
92
|
+
/** Reads and writes saves in a `Storage` — `localStorage` in a page. */
|
|
93
|
+
export class LocalStorageSaves {
|
|
94
|
+
storage;
|
|
95
|
+
constructor(storage) {
|
|
96
|
+
this.storage = storage;
|
|
97
|
+
}
|
|
98
|
+
load(key) {
|
|
99
|
+
const text = this.storage.getItem(key);
|
|
100
|
+
return text === null ? null : decodeSave(text);
|
|
101
|
+
}
|
|
102
|
+
/** Throws if the write fails — most often `QuotaExceededError`. */
|
|
103
|
+
store(key, bytes) {
|
|
104
|
+
this.storage.setItem(key, encodeSave(bytes));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `localStorage`, or `null` where touching it throws.
|
|
109
|
+
*
|
|
110
|
+
* It is not just absent in odd places: with storage blocked by the user, or in a
|
|
111
|
+
* sandboxed iframe, merely *reading the property* throws a `SecurityError`. A page
|
|
112
|
+
* that cannot save should still play.
|
|
113
|
+
*/
|
|
114
|
+
export function defaultStorage() {
|
|
115
|
+
try {
|
|
116
|
+
return globalThis.localStorage ?? null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
package/wasm/game_koi_web.d.ts
CHANGED
|
@@ -16,7 +16,27 @@ export class Emulator {
|
|
|
16
16
|
* so re-read both after loading a new ROM.
|
|
17
17
|
*/
|
|
18
18
|
frame_ptr(): number;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this cartridge has save RAM worth keeping.
|
|
21
|
+
*
|
|
22
|
+
* Same rule as the desktop's `save.rs`: a battery on the board *and* RAM to keep
|
|
23
|
+
* powered. RAM on a battery-less board is volatile on hardware too, so persisting
|
|
24
|
+
* it would invent a memory the cartridge never had. The mapper is asked rather
|
|
25
|
+
* than the header because MBC2's RAM is inside the mapper chip and its header
|
|
26
|
+
* declares none.
|
|
27
|
+
*/
|
|
28
|
+
has_save(): boolean;
|
|
19
29
|
static height(): number;
|
|
30
|
+
/**
|
|
31
|
+
* Restores save RAM from stored bytes. Returns whether it was applied.
|
|
32
|
+
*
|
|
33
|
+
* A save shorter than the chip is refused, as on the desktop: it is more likely a
|
|
34
|
+
* different game's than a truncated copy of this one's, and half-loading it would
|
|
35
|
+
* corrupt what the player has. A longer one is accepted, since some emulators
|
|
36
|
+
* append RTC state after the RAM.
|
|
37
|
+
*/
|
|
38
|
+
load_save(data: Uint8Array): boolean;
|
|
39
|
+
mark_saved(): void;
|
|
20
40
|
/**
|
|
21
41
|
* Builds a machine from a ROM image.
|
|
22
42
|
*
|
|
@@ -40,6 +60,21 @@ export class Emulator {
|
|
|
40
60
|
* somehow never completes a frame would otherwise wedge the browser tab.
|
|
41
61
|
*/
|
|
42
62
|
run_frame(): void;
|
|
63
|
+
/**
|
|
64
|
+
* A copy of the save RAM, or `undefined` if the cartridge has nothing to save.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately does not clear the dirty flag: if the page's write fails (storage
|
|
67
|
+
* full, private browsing), the save must still count as unwritten. The page calls
|
|
68
|
+
* [`Emulator::mark_saved`] once the bytes are actually stored.
|
|
69
|
+
*/
|
|
70
|
+
save_data(): Uint8Array | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Whether the game has written to save RAM since the last [`Emulator::mark_saved`].
|
|
73
|
+
*
|
|
74
|
+
* The page polls this rather than writing storage every frame: a game only
|
|
75
|
+
* touches save RAM when it saves, so almost every check is a cheap `false`.
|
|
76
|
+
*/
|
|
77
|
+
save_dirty(): boolean;
|
|
43
78
|
/**
|
|
44
79
|
* Takes the audio produced since the last call, interleaved as L,R,L,R.
|
|
45
80
|
*/
|
|
@@ -47,6 +82,15 @@ export class Emulator {
|
|
|
47
82
|
static width(): number;
|
|
48
83
|
}
|
|
49
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The workspace version this module was built from.
|
|
87
|
+
*
|
|
88
|
+
* Read from Cargo at compile time rather than from `package.json`, so it names the
|
|
89
|
+
* binary actually loaded: a stale `.wasm` cached next to a newer JS wrapper reports
|
|
90
|
+
* its own, older, number instead of the wrapper's.
|
|
91
|
+
*/
|
|
92
|
+
export function version(): string;
|
|
93
|
+
|
|
50
94
|
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
|
51
95
|
|
|
52
96
|
export interface InitOutput {
|
|
@@ -54,13 +98,19 @@ export interface InitOutput {
|
|
|
54
98
|
readonly __wbg_emulator_free: (a: number, b: number) => void;
|
|
55
99
|
readonly emulator_frame_len: (a: number) => number;
|
|
56
100
|
readonly emulator_frame_ptr: (a: number) => number;
|
|
101
|
+
readonly emulator_has_save: (a: number) => number;
|
|
57
102
|
readonly emulator_height: () => number;
|
|
103
|
+
readonly emulator_load_save: (a: number, b: number, c: number) => number;
|
|
104
|
+
readonly emulator_mark_saved: (a: number) => void;
|
|
58
105
|
readonly emulator_new: (a: number, b: number, c: number) => [number, number, number];
|
|
59
106
|
readonly emulator_press: (a: number, b: number, c: number) => void;
|
|
60
107
|
readonly emulator_release: (a: number, b: number, c: number) => void;
|
|
61
108
|
readonly emulator_run_frame: (a: number) => void;
|
|
109
|
+
readonly emulator_save_data: (a: number) => [number, number];
|
|
110
|
+
readonly emulator_save_dirty: (a: number) => number;
|
|
62
111
|
readonly emulator_take_samples: (a: number) => [number, number];
|
|
63
112
|
readonly emulator_width: () => number;
|
|
113
|
+
readonly version: () => [number, number];
|
|
64
114
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
|
65
115
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
|
66
116
|
readonly __externref_table_dealloc: (a: number) => void;
|
package/wasm/game_koi_web.js
CHANGED
|
@@ -33,6 +33,20 @@ export class Emulator {
|
|
|
33
33
|
const ret = wasm.emulator_frame_ptr(this.__wbg_ptr);
|
|
34
34
|
return ret >>> 0;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether this cartridge has save RAM worth keeping.
|
|
38
|
+
*
|
|
39
|
+
* Same rule as the desktop's `save.rs`: a battery on the board *and* RAM to keep
|
|
40
|
+
* powered. RAM on a battery-less board is volatile on hardware too, so persisting
|
|
41
|
+
* it would invent a memory the cartridge never had. The mapper is asked rather
|
|
42
|
+
* than the header because MBC2's RAM is inside the mapper chip and its header
|
|
43
|
+
* declares none.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
has_save() {
|
|
47
|
+
const ret = wasm.emulator_has_save(this.__wbg_ptr);
|
|
48
|
+
return ret !== 0;
|
|
49
|
+
}
|
|
36
50
|
/**
|
|
37
51
|
* @returns {number}
|
|
38
52
|
*/
|
|
@@ -40,6 +54,25 @@ export class Emulator {
|
|
|
40
54
|
const ret = wasm.emulator_height();
|
|
41
55
|
return ret >>> 0;
|
|
42
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Restores save RAM from stored bytes. Returns whether it was applied.
|
|
59
|
+
*
|
|
60
|
+
* A save shorter than the chip is refused, as on the desktop: it is more likely a
|
|
61
|
+
* different game's than a truncated copy of this one's, and half-loading it would
|
|
62
|
+
* corrupt what the player has. A longer one is accepted, since some emulators
|
|
63
|
+
* append RTC state after the RAM.
|
|
64
|
+
* @param {Uint8Array} data
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
load_save(data) {
|
|
68
|
+
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
|
69
|
+
const len0 = WASM_VECTOR_LEN;
|
|
70
|
+
const ret = wasm.emulator_load_save(this.__wbg_ptr, ptr0, len0);
|
|
71
|
+
return ret !== 0;
|
|
72
|
+
}
|
|
73
|
+
mark_saved() {
|
|
74
|
+
wasm.emulator_mark_saved(this.__wbg_ptr);
|
|
75
|
+
}
|
|
43
76
|
/**
|
|
44
77
|
* Builds a machine from a ROM image.
|
|
45
78
|
*
|
|
@@ -89,6 +122,34 @@ export class Emulator {
|
|
|
89
122
|
run_frame() {
|
|
90
123
|
wasm.emulator_run_frame(this.__wbg_ptr);
|
|
91
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* A copy of the save RAM, or `undefined` if the cartridge has nothing to save.
|
|
127
|
+
*
|
|
128
|
+
* Deliberately does not clear the dirty flag: if the page's write fails (storage
|
|
129
|
+
* full, private browsing), the save must still count as unwritten. The page calls
|
|
130
|
+
* [`Emulator::mark_saved`] once the bytes are actually stored.
|
|
131
|
+
* @returns {Uint8Array | undefined}
|
|
132
|
+
*/
|
|
133
|
+
save_data() {
|
|
134
|
+
const ret = wasm.emulator_save_data(this.__wbg_ptr);
|
|
135
|
+
let v1;
|
|
136
|
+
if (ret[0] !== 0) {
|
|
137
|
+
v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
|
138
|
+
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
|
139
|
+
}
|
|
140
|
+
return v1;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether the game has written to save RAM since the last [`Emulator::mark_saved`].
|
|
144
|
+
*
|
|
145
|
+
* The page polls this rather than writing storage every frame: a game only
|
|
146
|
+
* touches save RAM when it saves, so almost every check is a cheap `false`.
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
save_dirty() {
|
|
150
|
+
const ret = wasm.emulator_save_dirty(this.__wbg_ptr);
|
|
151
|
+
return ret !== 0;
|
|
152
|
+
}
|
|
92
153
|
/**
|
|
93
154
|
* Takes the audio produced since the last call, interleaved as L,R,L,R.
|
|
94
155
|
* @returns {Float32Array}
|
|
@@ -108,6 +169,27 @@ export class Emulator {
|
|
|
108
169
|
}
|
|
109
170
|
}
|
|
110
171
|
if (Symbol.dispose) Emulator.prototype[Symbol.dispose] = Emulator.prototype.free;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The workspace version this module was built from.
|
|
175
|
+
*
|
|
176
|
+
* Read from Cargo at compile time rather than from `package.json`, so it names the
|
|
177
|
+
* binary actually loaded: a stale `.wasm` cached next to a newer JS wrapper reports
|
|
178
|
+
* its own, older, number instead of the wrapper's.
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function version() {
|
|
182
|
+
let deferred1_0;
|
|
183
|
+
let deferred1_1;
|
|
184
|
+
try {
|
|
185
|
+
const ret = wasm.version();
|
|
186
|
+
deferred1_0 = ret[0];
|
|
187
|
+
deferred1_1 = ret[1];
|
|
188
|
+
return getStringFromWasm0(ret[0], ret[1]);
|
|
189
|
+
} finally {
|
|
190
|
+
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
111
193
|
function __wbg_get_imports() {
|
|
112
194
|
const import0 = {
|
|
113
195
|
__proto__: null,
|
|
@@ -143,6 +225,11 @@ function getArrayF32FromWasm0(ptr, len) {
|
|
|
143
225
|
return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
|
|
144
226
|
}
|
|
145
227
|
|
|
228
|
+
function getArrayU8FromWasm0(ptr, len) {
|
|
229
|
+
ptr = ptr >>> 0;
|
|
230
|
+
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
|
231
|
+
}
|
|
232
|
+
|
|
146
233
|
let cachedFloat32ArrayMemory0 = null;
|
|
147
234
|
function getFloat32ArrayMemory0() {
|
|
148
235
|
if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) {
|
|
Binary file
|
|
@@ -4,13 +4,19 @@ export const memory: WebAssembly.Memory;
|
|
|
4
4
|
export const __wbg_emulator_free: (a: number, b: number) => void;
|
|
5
5
|
export const emulator_frame_len: (a: number) => number;
|
|
6
6
|
export const emulator_frame_ptr: (a: number) => number;
|
|
7
|
+
export const emulator_has_save: (a: number) => number;
|
|
7
8
|
export const emulator_height: () => number;
|
|
9
|
+
export const emulator_load_save: (a: number, b: number, c: number) => number;
|
|
10
|
+
export const emulator_mark_saved: (a: number) => void;
|
|
8
11
|
export const emulator_new: (a: number, b: number, c: number) => [number, number, number];
|
|
9
12
|
export const emulator_press: (a: number, b: number, c: number) => void;
|
|
10
13
|
export const emulator_release: (a: number, b: number, c: number) => void;
|
|
11
14
|
export const emulator_run_frame: (a: number) => void;
|
|
15
|
+
export const emulator_save_data: (a: number) => [number, number];
|
|
16
|
+
export const emulator_save_dirty: (a: number) => number;
|
|
12
17
|
export const emulator_take_samples: (a: number) => [number, number];
|
|
13
18
|
export const emulator_width: () => number;
|
|
19
|
+
export const version: () => [number, number];
|
|
14
20
|
export const __wbindgen_externrefs: WebAssembly.Table;
|
|
15
21
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
|
16
22
|
export const __externref_table_dealloc: (a: number) => void;
|