@wasm-gaming/rsdkv5-wasm 0.1.2 → 0.2.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/src/rsdkv5.sdk.ts CHANGED
@@ -71,9 +71,13 @@ interface Rsdkv5Module {
71
71
  };
72
72
  callMain(args: string[]): void;
73
73
  pauseMainLoop?: () => void;
74
+ resumeMainLoop?: () => void;
74
75
  web_devmenu_get_stage_list?: () => string;
75
76
  web_devmenu_load_stage?: (category: number, stage: number) => void;
76
77
  web_devmenu_set_paused?: (paused: boolean) => void;
78
+ _web_mania_save_status?: () => number;
79
+ _web_mania_start_slot?: (slot: number, character: number) => number;
80
+ _web_mania_characters?: () => number;
77
81
  SDL2?: { audioContext?: AudioContext };
78
82
  }
79
83
 
@@ -85,6 +89,87 @@ export interface RsdkDevMenuBridge {
85
89
  loadStage(category: number, stage: number): void;
86
90
  }
87
91
 
92
+ /** Mania's characters, by the `characterID` its save slots store. */
93
+ export const MANIA_CHARACTERS = ['Sonic & Tails', 'Sonic', 'Tails', 'Knuckles', 'Mighty', 'Ray'] as const;
94
+
95
+ /** Mania Mode's zones, by the `zoneID` its save slots store. */
96
+ export const MANIA_ZONES = [
97
+ 'Green Hill', 'Chemical Plant', 'Studiopolis', 'Flying Battery', 'Press Garden', 'Stardust Speedway',
98
+ 'Hydrocity', 'Mirage Saloon', 'Oil Ocean', 'Lava Reef', 'Metallic Madness', 'Titanic Monarch', 'Egg Reverie',
99
+ ] as const;
100
+
101
+ /** One Mania Mode save slot, as `SaveData.bin` has it. */
102
+ export interface ManiaSaveSlot {
103
+ /** 0-7, the slot Mania numbers 1-8. */
104
+ slot: number;
105
+ state: 'empty' | 'progress' | 'complete';
106
+ /** Index into {@link MANIA_CHARACTERS}. */
107
+ character: number;
108
+ /** Index into {@link MANIA_ZONES}; meaningful while in progress. */
109
+ zone: number;
110
+ zoneName: string;
111
+ lives: number;
112
+ score: number;
113
+ /** How many of the seven Chaos Emeralds (the save keeps a bit mask). */
114
+ emeralds: number;
115
+ continues: number;
116
+ }
117
+
118
+ /** Mania Plus's eight regular save slots in `SaveData.bin` (Encore slots are elsewhere). */
119
+ export const MANIA_SAVE_SLOTS = 8;
120
+ const SLOT_BYTES = 0x400;
121
+ const SLOT_FIELDS = 0x58; // SaveRAM: the entity header Mania keeps before the fields
122
+
123
+ /**
124
+ * Mania Mode's save slots from `SaveData.bin` (`globals->saveRAM`, int32 LE): slot `s`
125
+ * is the 0x400-byte block at `0x400 * s`, fields from +0x58. A missing or short file
126
+ * reads as empty slots.
127
+ */
128
+ export function parseManiaSaves(bytes: Uint8Array | null): ManiaSaveSlot[] {
129
+ const view = bytes ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
130
+ return Array.from({ length: MANIA_SAVE_SLOTS }, (_, slot) => {
131
+ const base = slot * SLOT_BYTES + SLOT_FIELDS;
132
+ const field = (i: number): number => (view && base + i * 4 + 4 <= view.byteLength ? view.getInt32(base + i * 4, true) : 0);
133
+ const state = field(0) === 1 ? 'progress' : field(0) === 2 ? 'complete' : 'empty';
134
+ const character = Math.max(0, Math.min(MANIA_CHARACTERS.length - 1, field(1)));
135
+ const zone = field(2);
136
+ let emeralds = 0;
137
+ for (let mask = field(6) & 0x7f; mask; mask >>= 1) emeralds += mask & 1;
138
+ return {
139
+ slot,
140
+ state,
141
+ character: state === 'empty' ? 0 : character,
142
+ zone: state === 'empty' ? 0 : zone,
143
+ zoneName: state === 'complete' ? 'Complete' : state === 'empty' ? '' : (MANIA_ZONES[zone] ?? 'Unknown zone'),
144
+ lives: state === 'empty' ? 3 : field(3),
145
+ score: state === 'empty' ? 0 : field(4),
146
+ emeralds: state === 'empty' ? 0 : emeralds,
147
+ continues: state === 'empty' ? 0 : field(7),
148
+ };
149
+ });
150
+ }
151
+
152
+ /** Mania Mode's save slots, read and started the way Mania's own save select does. */
153
+ export interface ManiaSaves {
154
+ /** True when this build has the bridge (older wasm builds don't). */
155
+ readonly available: boolean;
156
+ /** How many characters a new game can pick: 6 with Plus (Mighty, Ray), else 4. */
157
+ readonly characters: number;
158
+ /** The eight slots, from the save file as it is now. */
159
+ list(): ManiaSaveSlot[];
160
+ /**
161
+ * Resolves true once Mania has its save file loaded (it asks Mania to load it), false
162
+ * if that failed or took longer than `timeoutMs`.
163
+ */
164
+ ready(timeoutMs?: number): Promise<boolean>;
165
+ /**
166
+ * Start a slot: an empty one is a new game with `character` (an index into
167
+ * {@link MANIA_CHARACTERS}), a saved one continues with its own. Mania writes the
168
+ * save and loads the stage; the pause is lifted. False when the save isn't ready.
169
+ */
170
+ start(slot: number, character?: number): boolean;
171
+ }
172
+
88
173
  /**
89
174
  * Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
90
175
  * root. Identical in every wasm-gaming engine, written to move verbatim into
@@ -191,6 +276,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
191
276
  #module: Rsdkv5Module | null = null;
192
277
  #destroyed = false;
193
278
  #pauseOwner: string | null = null;
279
+ #muted = false;
194
280
 
195
281
  #canvas: HTMLCanvasElement | null = null;
196
282
  #ownsCanvas = false;
@@ -225,6 +311,43 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
225
311
  return this.#pauseOwner;
226
312
  }
227
313
 
314
+ /** Mania Mode's save slots — see {@link ManiaSaves}. */
315
+ get saves(): ManiaSaves {
316
+ const Module = this.#module;
317
+ const workDir = this.#workDir;
318
+ const available = !!Module?._web_mania_save_status && !!Module._web_mania_start_slot;
319
+ return {
320
+ available,
321
+ characters: available && Module?._web_mania_characters ? Module._web_mania_characters() : 4,
322
+ list: () => {
323
+ let bytes: Uint8Array | null = null;
324
+ try {
325
+ bytes = Module ? new Uint8Array(Module.FS.readFile(`${workDir}/SaveData.bin`)) : null;
326
+ } catch {
327
+ bytes = null; // no save file yet: eight empty slots
328
+ }
329
+ return parseManiaSaves(bytes);
330
+ },
331
+ ready: async (timeoutMs = 5000) => {
332
+ if (!available || !Module) return false;
333
+ const deadline = performance.now() + timeoutMs;
334
+ for (;;) {
335
+ const status = Module._web_mania_save_status!();
336
+ if (status === 200) return true;
337
+ if ((status !== 0 && status !== 100) || performance.now() > deadline) return false;
338
+ await new Promise((resolve) => setTimeout(resolve, 50));
339
+ }
340
+ },
341
+ start: (slot, character = 0) => {
342
+ if (!available || !Module) return false;
343
+ if (!Module._web_mania_start_slot!(slot | 0, character | 0)) return false;
344
+ // Whatever drove this is done with the engine: hand it back running.
345
+ if (this.#pauseOwner) this.resume(this.#pauseOwner);
346
+ return true;
347
+ },
348
+ };
349
+ }
350
+
228
351
  /** RSDKv5's stage list, for a host that draws its own stage select. */
229
352
  get devMenu(): RsdkDevMenuBridge {
230
353
  const Module = (): Rsdkv5Module | null => this.#module;
@@ -251,6 +374,9 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
251
374
  if (!Module || this.#pauseOwner) return;
252
375
  this.#pauseOwner = owner;
253
376
  Module.web_devmenu_set_paused?.(true);
377
+ // This build has no native pause hook (web_devmenu_* are not compiled in), so the
378
+ // game is frozen by stopping emscripten's main loop: no frame runs until resume().
379
+ Module.pauseMainLoop?.();
254
380
  void Module.SDL2?.audioContext?.suspend();
255
381
  void this.flushSaves();
256
382
  this.emit('pause', { owner });
@@ -260,11 +386,28 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
260
386
  const Module = this.#module;
261
387
  if (!Module || this.#pauseOwner !== owner) return;
262
388
  this.#pauseOwner = null;
263
- void Module.SDL2?.audioContext?.resume();
389
+ if (!this.#muted) void Module.SDL2?.audioContext?.resume();
264
390
  Module.web_devmenu_set_paused?.(false);
391
+ Module.resumeMainLoop?.();
265
392
  this.emit('resume', { owner });
266
393
  }
267
394
 
395
+ /**
396
+ * Silence the game without stopping it (the frames keep running). A host uses it to
397
+ * let Mania get somewhere — load its save file — behind its own screen. Independent
398
+ * of {@link pause}: a paused game stays silent either way.
399
+ */
400
+ mute(muted = true): void {
401
+ this.#muted = muted;
402
+ const context = this.#module?.SDL2?.audioContext;
403
+ if (muted) void context?.suspend();
404
+ else if (!this.#pauseOwner) void context?.resume();
405
+ }
406
+
407
+ get muted(): boolean {
408
+ return this.#muted;
409
+ }
410
+
268
411
  /** Copy the game's changed user files to OPFS `rsdkv5/<namespace>/`; emits `saves`. */
269
412
  flushSaves(): Promise<void> {
270
413
  this.#flushing ??= this.#flush().finally(() => {
@@ -471,7 +614,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
471
614
  export class Rsdkv5SDK extends EngineSDKBase<Rsdkv5Payloads> {
472
615
  static readonly id = ENGINE_ID;
473
616
  /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
474
- static readonly version = '0.1.2';
617
+ static readonly version = '0.2.0';
475
618
  static readonly name = 'Retro Software Development Kit v3';
476
619
  static readonly description =
477
620
  'RSDKv5U (Sonic Mania decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';