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 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
- constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake) {
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 koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, gamepadMap, options.overlayKey === null ? null : options.overlayKey ?? "Backquote", options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
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];
@@ -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.6.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
  */
@@ -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;
@@ -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;