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 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
- constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake) {
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 koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, gamepadMap, options.overlayKey === null ? null : options.overlayKey ?? "Backquote", options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
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];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "game-koi",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A Game Boy (DMG) emulator, compiled to WebAssembly, with a drop-in canvas + audio wrapper",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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;
@@ -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;