game-koi 0.5.0 → 0.6.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 +29 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +115 -2
- 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 +40 -0
- package/wasm/game_koi_web.js +66 -0
- package/wasm/game_koi_web_bg.wasm +0 -0
- package/wasm/game_koi_web_bg.wasm.d.ts +5 -0
package/README.md
CHANGED
|
@@ -77,6 +77,30 @@ 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.
|
|
87
|
+
|
|
88
|
+
A few things worth knowing:
|
|
89
|
+
|
|
90
|
+
- **Only battery-backed cartridges are saved**, as on hardware: RAM on a board without
|
|
91
|
+
a battery is gone when the power is. Check with `koi.exportSave() !== null`.
|
|
92
|
+
- Saves are keyed by the ROM's header title plus a CRC32 of the whole ROM, so two
|
|
93
|
+
ROMs sharing a title (a hack and its original, say) do not share a save.
|
|
94
|
+
- `localStorage` is per origin and holds roughly 5 MiB. The largest Game Boy save is
|
|
95
|
+
128 KiB (about 171 KiB stored), so that is plenty for a handful of games but not an
|
|
96
|
+
unlimited library. A write that hits the quota is logged and retried later, not lost.
|
|
97
|
+
- `koi.exportSave()` / `koi.importSave(bytes)` read and replace the raw save RAM — the
|
|
98
|
+
same `.sav` format the desktop build and most other emulators use — for download or
|
|
99
|
+
upload buttons. A game only re-reads its save at its own pace, usually on the title
|
|
100
|
+
screen, so call `loadRom()` after an import if you want it picked up at once.
|
|
101
|
+
- Pass `saves: false` to `create()` to leave `localStorage` alone and handle saves
|
|
102
|
+
entirely through `exportSave`/`importSave` — for IndexedDB, or a server.
|
|
103
|
+
|
|
80
104
|
### Debug overlay
|
|
81
105
|
|
|
82
106
|
Press <kbd>`</kbd> (backtick) to toggle a stats panel over the canvas, or call
|
|
@@ -114,7 +138,12 @@ element. Pass `overlayKey: null` to bind no key.
|
|
|
114
138
|
- `targetBuffer?: number` — audio samples to keep queued ahead. Default `1600`.
|
|
115
139
|
- `maxFramesPerWake?: number` — cap on frames emulated per wake-up, so a
|
|
116
140
|
backgrounded tab can't return to a freeze. Default `4`.
|
|
141
|
+
- `saves?: boolean` — keep battery-backed saves in `localStorage`. Default `true`.
|
|
117
142
|
- `koi.loadRom(rom)` — swaps in a new ROM, keeping the same canvas and audio setup.
|
|
143
|
+
- `koi.exportSave()` — the cartridge's save RAM as a `Uint8Array`, or `null` if it
|
|
144
|
+
has no battery-backed RAM.
|
|
145
|
+
- `koi.importSave(bytes)` — replaces the save RAM (and stores it, if saving is on).
|
|
146
|
+
Returns `false` if the cartridge has none or `bytes` is shorter than it.
|
|
118
147
|
- `koi.press(button)` / `koi.release(button)` — `Button` is one of `"up"`, `"down"`,
|
|
119
148
|
`"left"`, `"right"`, `"a"`, `"b"`, `"start"`, `"select"`.
|
|
120
149
|
- `koi.on(type, listener)` / `koi.off(type, listener)` — `"press"` and `"release"`
|
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,6 +93,22 @@ 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;
|
|
85
114
|
/** Everything the joypad has down right now — a snapshot, safe to keep. */
|
|
@@ -143,6 +172,26 @@ export declare class GameKoi {
|
|
|
143
172
|
*/
|
|
144
173
|
private pollGamepads;
|
|
145
174
|
private applyGamepadActions;
|
|
175
|
+
/** Loads the stored save for `rom` into the machine just built for it. */
|
|
176
|
+
private restoreSave;
|
|
177
|
+
/**
|
|
178
|
+
* Writes the save if the game has touched its RAM since the last write.
|
|
179
|
+
*
|
|
180
|
+
* The dirty flag is cleared only after the write succeeds, so a full quota or
|
|
181
|
+
* blocked storage leaves the save pending for the next attempt rather than
|
|
182
|
+
* silently dropped.
|
|
183
|
+
*/
|
|
184
|
+
private flushSave;
|
|
185
|
+
/**
|
|
186
|
+
* Flushes when the page goes away.
|
|
187
|
+
*
|
|
188
|
+
* `pagehide` and a hidden `visibilitychange` rather than `beforeunload`, which
|
|
189
|
+
* mobile browsers skip when they kill a backgrounded tab. Hidden is the last moment
|
|
190
|
+
* a page is reliably given, and `localStorage` being synchronous is what makes
|
|
191
|
+
* writing in it safe — an async store could be cut off mid-write.
|
|
192
|
+
*/
|
|
193
|
+
private attachSaveFlush;
|
|
194
|
+
private detachSaveFlush;
|
|
146
195
|
private attachKeyboard;
|
|
147
196
|
private detachKeyboard;
|
|
148
197
|
/**
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,13 @@ 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",
|
|
@@ -58,7 +65,13 @@ export class GameKoi {
|
|
|
58
65
|
keydownListener;
|
|
59
66
|
keyupListener;
|
|
60
67
|
overlayListener;
|
|
61
|
-
|
|
68
|
+
/** Where saves go, or `null` if saving is off or storage is unavailable. */
|
|
69
|
+
saves;
|
|
70
|
+
/** The current ROM's storage key. */
|
|
71
|
+
saveKey = "";
|
|
72
|
+
framesSinceAutosave = 0;
|
|
73
|
+
flushListener;
|
|
74
|
+
constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake, saves) {
|
|
62
75
|
this.emulator = emulator;
|
|
63
76
|
this.wasmMemory = wasmMemory;
|
|
64
77
|
this.ctx = ctx;
|
|
@@ -70,6 +83,7 @@ export class GameKoi {
|
|
|
70
83
|
this.maxFramesPerWake = maxFramesPerWake;
|
|
71
84
|
this.imageData = ctx.createImageData(Emulator.width(), Emulator.height());
|
|
72
85
|
this.stats = new FrameStats(performance.now());
|
|
86
|
+
this.saves = saves;
|
|
73
87
|
this.worklet.port.onmessage = (event) => {
|
|
74
88
|
this.buffered = event.data.buffered;
|
|
75
89
|
this.underruns = event.data.underruns;
|
|
@@ -79,6 +93,8 @@ export class GameKoi {
|
|
|
79
93
|
this.attachKeyboard();
|
|
80
94
|
if (overlayKey !== null)
|
|
81
95
|
this.attachOverlayKey(overlayKey);
|
|
96
|
+
if (saves)
|
|
97
|
+
this.attachSaveFlush();
|
|
82
98
|
}
|
|
83
99
|
/**
|
|
84
100
|
* Builds and starts a running emulator.
|
|
@@ -109,7 +125,9 @@ export class GameKoi {
|
|
|
109
125
|
const emulator = new Emulator(options.rom, audioContext.sampleRate);
|
|
110
126
|
const keymap = options.keymap === null ? null : options.keymap ?? DEFAULT_KEYMAP;
|
|
111
127
|
const gamepadMap = options.gamepadMap === null ? null : options.gamepadMap ?? DEFAULT_GAMEPAD_MAP;
|
|
112
|
-
const
|
|
128
|
+
const storage = options.saves === false ? null : defaultStorage();
|
|
129
|
+
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);
|
|
130
|
+
koi.restoreSave(options.rom);
|
|
113
131
|
koi.running = true;
|
|
114
132
|
koi.loop();
|
|
115
133
|
return koi;
|
|
@@ -123,8 +141,40 @@ export class GameKoi {
|
|
|
123
141
|
// machine, and a display would show it stuck down).
|
|
124
142
|
this.gamepad?.releaseAll();
|
|
125
143
|
this.releaseAll();
|
|
144
|
+
// Before the old machine is freed, or whatever it saved since the last autosave
|
|
145
|
+
// goes with it.
|
|
146
|
+
this.flushSave();
|
|
126
147
|
this.emulator.free();
|
|
127
148
|
this.emulator = new Emulator(rom, this.audioContext.sampleRate);
|
|
149
|
+
this.restoreSave(rom);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The cartridge's save RAM, or `null` if it has no battery-backed RAM.
|
|
153
|
+
*
|
|
154
|
+
* The same raw bytes the desktop build writes to its `.sav` file, so either can be
|
|
155
|
+
* handed to the other — and to most other emulators, which use the same format.
|
|
156
|
+
*/
|
|
157
|
+
exportSave() {
|
|
158
|
+
return this.emulator.save_data() ?? null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Replaces the cartridge's save RAM, and stores it if saving is on.
|
|
162
|
+
*
|
|
163
|
+
* Returns `false` if the cartridge has no battery-backed RAM or the data is shorter
|
|
164
|
+
* than its RAM (more likely another game's save than a truncated one of this game's).
|
|
165
|
+
* The running game will not notice until it next reads its save — for most games,
|
|
166
|
+
* that means going back to the title screen or calling {@link loadRom} again.
|
|
167
|
+
*/
|
|
168
|
+
importSave(data) {
|
|
169
|
+
if (!this.emulator.load_save(data))
|
|
170
|
+
return false;
|
|
171
|
+
try {
|
|
172
|
+
this.saves?.store(this.saveKey, data);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
console.warn("game-koi: could not store imported save", error);
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
128
178
|
}
|
|
129
179
|
press(button) {
|
|
130
180
|
this.setButton(button, true, "api");
|
|
@@ -202,6 +252,7 @@ export class GameKoi {
|
|
|
202
252
|
return this.overlay?.visible ?? false;
|
|
203
253
|
}
|
|
204
254
|
pause() {
|
|
255
|
+
this.flushSave();
|
|
205
256
|
this.running = false;
|
|
206
257
|
cancelAnimationFrame(this.rafHandle);
|
|
207
258
|
// A direction held at the moment of the pause has no poll coming to release it,
|
|
@@ -220,6 +271,8 @@ export class GameKoi {
|
|
|
220
271
|
dispose() {
|
|
221
272
|
this.running = false;
|
|
222
273
|
cancelAnimationFrame(this.rafHandle);
|
|
274
|
+
this.flushSave();
|
|
275
|
+
this.detachSaveFlush();
|
|
223
276
|
this.detachKeyboard();
|
|
224
277
|
if (this.overlayListener)
|
|
225
278
|
removeEventListener("keydown", this.overlayListener);
|
|
@@ -258,6 +311,11 @@ export class GameKoi {
|
|
|
258
311
|
}
|
|
259
312
|
frames++;
|
|
260
313
|
}
|
|
314
|
+
this.framesSinceAutosave += frames;
|
|
315
|
+
if (this.framesSinceAutosave >= AUTOSAVE_FRAMES) {
|
|
316
|
+
this.framesSinceAutosave = 0;
|
|
317
|
+
this.flushSave();
|
|
318
|
+
}
|
|
261
319
|
const drawStart = performance.now();
|
|
262
320
|
if (frames > 0)
|
|
263
321
|
this.drawFrame();
|
|
@@ -321,6 +379,61 @@ export class GameKoi {
|
|
|
321
379
|
this.setButton(action.button, action.type === "press", "gamepad");
|
|
322
380
|
}
|
|
323
381
|
}
|
|
382
|
+
/** Loads the stored save for `rom` into the machine just built for it. */
|
|
383
|
+
restoreSave(rom) {
|
|
384
|
+
this.framesSinceAutosave = 0;
|
|
385
|
+
if (!this.saves)
|
|
386
|
+
return;
|
|
387
|
+
this.saveKey = saveKey(rom);
|
|
388
|
+
if (!this.emulator.has_save())
|
|
389
|
+
return;
|
|
390
|
+
const data = this.saves.load(this.saveKey);
|
|
391
|
+
if (data && !this.emulator.load_save(data)) {
|
|
392
|
+
console.warn(`game-koi: ignoring ${this.saveKey}, it is smaller than this cartridge's RAM`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Writes the save if the game has touched its RAM since the last write.
|
|
397
|
+
*
|
|
398
|
+
* The dirty flag is cleared only after the write succeeds, so a full quota or
|
|
399
|
+
* blocked storage leaves the save pending for the next attempt rather than
|
|
400
|
+
* silently dropped.
|
|
401
|
+
*/
|
|
402
|
+
flushSave() {
|
|
403
|
+
if (!this.saves || !this.emulator.save_dirty())
|
|
404
|
+
return;
|
|
405
|
+
const data = this.emulator.save_data();
|
|
406
|
+
if (!data)
|
|
407
|
+
return;
|
|
408
|
+
try {
|
|
409
|
+
this.saves.store(this.saveKey, data);
|
|
410
|
+
this.emulator.mark_saved();
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
console.warn("game-koi: could not store save", error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Flushes when the page goes away.
|
|
418
|
+
*
|
|
419
|
+
* `pagehide` and a hidden `visibilitychange` rather than `beforeunload`, which
|
|
420
|
+
* mobile browsers skip when they kill a backgrounded tab. Hidden is the last moment
|
|
421
|
+
* a page is reliably given, and `localStorage` being synchronous is what makes
|
|
422
|
+
* writing in it safe — an async store could be cut off mid-write.
|
|
423
|
+
*/
|
|
424
|
+
attachSaveFlush() {
|
|
425
|
+
// Unconditional, since a flush with nothing dirty is a no-op: becoming *visible*
|
|
426
|
+
// costs one check, and `pagehide` need not trust the visibility state to be set yet.
|
|
427
|
+
this.flushListener = () => this.flushSave();
|
|
428
|
+
addEventListener("pagehide", this.flushListener);
|
|
429
|
+
document.addEventListener("visibilitychange", this.flushListener);
|
|
430
|
+
}
|
|
431
|
+
detachSaveFlush() {
|
|
432
|
+
if (!this.flushListener)
|
|
433
|
+
return;
|
|
434
|
+
removeEventListener("pagehide", this.flushListener);
|
|
435
|
+
document.removeEventListener("visibilitychange", this.flushListener);
|
|
436
|
+
}
|
|
324
437
|
attachKeyboard() {
|
|
325
438
|
this.keydownListener = (event) => {
|
|
326
439
|
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
|
*/
|
|
@@ -54,11 +89,16 @@ export interface InitOutput {
|
|
|
54
89
|
readonly __wbg_emulator_free: (a: number, b: number) => void;
|
|
55
90
|
readonly emulator_frame_len: (a: number) => number;
|
|
56
91
|
readonly emulator_frame_ptr: (a: number) => number;
|
|
92
|
+
readonly emulator_has_save: (a: number) => number;
|
|
57
93
|
readonly emulator_height: () => number;
|
|
94
|
+
readonly emulator_load_save: (a: number, b: number, c: number) => number;
|
|
95
|
+
readonly emulator_mark_saved: (a: number) => void;
|
|
58
96
|
readonly emulator_new: (a: number, b: number, c: number) => [number, number, number];
|
|
59
97
|
readonly emulator_press: (a: number, b: number, c: number) => void;
|
|
60
98
|
readonly emulator_release: (a: number, b: number, c: number) => void;
|
|
61
99
|
readonly emulator_run_frame: (a: number) => void;
|
|
100
|
+
readonly emulator_save_data: (a: number) => [number, number];
|
|
101
|
+
readonly emulator_save_dirty: (a: number) => number;
|
|
62
102
|
readonly emulator_take_samples: (a: number) => [number, number];
|
|
63
103
|
readonly emulator_width: () => number;
|
|
64
104
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
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}
|
|
@@ -143,6 +204,11 @@ function getArrayF32FromWasm0(ptr, len) {
|
|
|
143
204
|
return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
|
|
144
205
|
}
|
|
145
206
|
|
|
207
|
+
function getArrayU8FromWasm0(ptr, len) {
|
|
208
|
+
ptr = ptr >>> 0;
|
|
209
|
+
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
|
210
|
+
}
|
|
211
|
+
|
|
146
212
|
let cachedFloat32ArrayMemory0 = null;
|
|
147
213
|
function getFloat32ArrayMemory0() {
|
|
148
214
|
if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) {
|
|
Binary file
|
|
@@ -4,11 +4,16 @@ 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;
|
|
14
19
|
export const __wbindgen_externrefs: WebAssembly.Table;
|