romdevtools 0.84.1 → 0.86.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/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.86.0 — 2026-07-01
8
+
9
+ - **GameTank Game Genie — a brand-new cheat-code format.** Nobody had made GameTank cheat codes
10
+ before; this adds a Game-Genie-style read-substitution device to the emulated GameTank core plus
11
+ a code format that is HARDWARE-COMPATIBLE (the same codes would work on a physical Game Genie
12
+ built for the console's open cart bus).
13
+ - Core (`romdev-core-gametank@0.2.0`): a value-override cheat device in the shared debug lib
14
+ (`romdev_cheat_set`/`romdev_cheat_read`/`romdev_cheat_get`, 24 slots). `MemoryRead` returns the
15
+ substitute byte on an address match, with optional compare-against-original (survives bank
16
+ switching) — exactly what a hardware Game Genie does. Any core that adds the one-line
17
+ `romdev_cheat_read` call to its bus read gets this for free.
18
+ - Format: `encodeGameTankGameGenie`/`decodeGameTankGameGenie` — a distinct 16-letter wheel
19
+ (`KLMNPQRSTVWXYZ23`), 16-bit address + 8-bit value (+ optional compare), scrambled with a
20
+ checksum. Plain codes are `XXX-XXXX`, compare codes `XXXX-XXXXX` (e.g. `$8100→0x42` = KTM-LPK3).
21
+ Wired into every device dispatcher; `cheats({op:'make', platform:'gametank', …})` generates
22
+ codes and `cheats({op:'apply'})` applies them live. GameTank's `retro_cheat_set` is a stub, so
23
+ `LibretroHost.setCheat` routes GameTank cheats through the romdev value-override device.
24
+ - **jsgame test no longer skips.** It ran only under `--experimental-vm-modules`, so the default
25
+ suite skipped it. It now runs in an isolated forked child process (SDL is main-thread-only;
26
+ rungame leaks handles) driven over rawr JSON-RPC — the child carries the flag and is killed when
27
+ the RPC resolves, so the main suite stays clean. Suite: 1099 tests, 1099 pass, 0 skipped.
28
+
29
+ ## 0.85.0 — 2026-07-01
30
+
31
+ - **Native-runtime game kinds — wasmcart + jsgame are now first-class hosts.** romdev is a
32
+ uniform harness for runnable game artifacts; libretro emulator cores were one kind, and now
33
+ wasmcart carts (`.wasc`) and jsgame web games (`.jsgame`) are two more. They run native
34
+ WASM/JS in-process (not emulation) but share the same run/see/drive surface:
35
+ `loadMedia({platform:'wasmcart'|'jsgame'}) → frame(step/screenshot/verify) → input`, driven
36
+ through the existing tools. A capability descriptor per host keeps emulator-only tools
37
+ (memory regions, cpuState, disasm, cheats) absent for these kinds.
38
+ - `WasmcartHost` wraps wasmcart's `CartHost.runFrame`; framebuffer is XRGB8888 (decoded as-is).
39
+ **WASM introspection** (the V8-runtime bonus): read/write the real cart heap, enumerate the
40
+ module's exports, read the WCInfo. Needs `wasmcart@^0.3.0` (its new `setFixedStep`).
41
+ - `JsGameHost` wraps jsgamelauncher's new headless `createHostSession` (`rungame@^0.13.0`):
42
+ host-stepped rAF + offscreen-canvas readback, synthetic gamepad input, **JS introspection**
43
+ (`globalThis._jsg`). Requires the server to run with `--experimental-vm-modules` — the server
44
+ now SELF-RE-EXECS with that flag if missing (harmless for everything else).
45
+ - **`pack` tool — the "build" (zip) step for native kinds.** `pack({target:'wasc'|'jsgame',
46
+ source, outputPath})` assembles a source directory into its distributable `.wasc`/`.jsgame`
47
+ archive (a zip; NOT a compiler — wasmcart is any-language→WASM, jsgame is plain JS). Rounds
48
+ out the build→run→see loop for these kinds. Zero new deps (fflate).
49
+
7
50
  ## 0.84.1 — 2026-06-30
8
51
 
9
52
  - **New package `romdev-audio-resampler`** — the WASM+SIMD linear audio resampler (interleaved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.84.1",
3
+ "version": "0.86.0",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 17 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, PlayStation, N64, Dreamcast, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -58,7 +58,7 @@
58
58
  "romdev-core-fceumm": "0.11.0",
59
59
  "romdev-core-flycast": "0.2.0",
60
60
  "romdev-core-gambatte": "0.10.0",
61
- "romdev-core-gametank": "0.1.0",
61
+ "romdev-core-gametank": "0.2.0",
62
62
  "romdev-core-geargrafx": "0.8.0",
63
63
  "romdev-core-gpgx": "0.13.0",
64
64
  "romdev-core-handy": "0.8.0",
@@ -79,8 +79,10 @@
79
79
  "romdev-toolchain-vasm": "0.1.1",
80
80
  "romdev-xgm2": "0.1.0",
81
81
  "romdev_game_codes": "0.1.0",
82
+ "rungame": "^0.13.0",
82
83
  "socket.io": "^4.8.3",
83
84
  "swagger-ui-dist": "^5.17.14",
85
+ "wasmcart": "^0.3.0",
84
86
  "yauzl": "^3.3.1",
85
87
  "zod": "^4.4.3"
86
88
  },
@@ -108,5 +110,8 @@
108
110
  "homepage": "https://github.com/monteslu/romdev#readme",
109
111
  "bugs": {
110
112
  "url": "https://github.com/monteslu/romdev/issues"
113
+ },
114
+ "devDependencies": {
115
+ "rawr": "^1.0.1"
111
116
  }
112
117
  }
@@ -277,6 +277,98 @@ export function decodeSnesGameGenie(code) {
277
277
  return { address, value };
278
278
  }
279
279
 
280
+ // ── GameTank Game Genie ─────────────────────────────────────────────────
281
+ // A NEW format for Clyde Shaffer's open GameTank console — no prior art exists
282
+ // (nobody has made GameTank cheat codes before). The GameTank CPU (W65C02S) sees a
283
+ // flat 16-bit address space, so a code encodes a 16-bit READ address + an 8-bit
284
+ // substitute value, plus an optional 8-bit compare byte (the compare form survives
285
+ // the cart's flash bank switching, exactly like NES/GB compare codes). The device
286
+ // intercepts the CPU's bus read and substitutes the value — identical behaviour to
287
+ // a hardware Game Genie you could build for the console's open cart bus.
288
+ //
289
+ // Encoding: a distinct 16-letter wheel (so codes read as GameTank, not NES). The
290
+ // payload nibbles are laid out and lightly scrambled so a small address change moves
291
+ // several letters (the GG "feel"), then a 1-nibble checksum guards typos.
292
+ // plain: 6 payload nibbles (addr16 + val8) + 1 checksum = 7 letters, shown "XXX-XXXX"
293
+ // compare: 8 payload nibbles (addr16 + val8 + cmp8) + 1 checksum = 9 letters, "XXXX-XXXXX"
294
+ const GT_GG_LETTERS = "KLMNPQRSTVWXYZ23"; // 16 distinct glyphs, no vowels/0-1-O-I ambiguity
295
+
296
+ function gtNibblesToLetters(nibs) {
297
+ return nibs.map((x) => GT_GG_LETTERS[x & 0xF]).join("");
298
+ }
299
+ function gtLettersToNibbles(code) {
300
+ const clean = code.replace(/-/g, "").toUpperCase();
301
+ const n = [];
302
+ for (const ch of clean) {
303
+ const v = GT_GG_LETTERS.indexOf(ch);
304
+ if (v < 0) return null;
305
+ n.push(v);
306
+ }
307
+ return n;
308
+ }
309
+ // Simple nibble checksum: XOR of all payload nibbles, folded to 4 bits.
310
+ function gtChecksum(payloadNibs) {
311
+ let c = 0;
312
+ for (const x of payloadNibs) c ^= x & 0xF;
313
+ return c & 0xF;
314
+ }
315
+ // Scramble/unscramble the payload-nibble ORDER (a fixed permutation) so a code
316
+ // doesn't read as plain hex. Self-inverse pairs keep decode trivial.
317
+ const GT_PERM6 = [3, 0, 5, 1, 4, 2]; // plain: 6 nibbles
318
+ const GT_PERM8 = [5, 2, 7, 0, 4, 1, 6, 3]; // compare: 8 nibbles
319
+ function applyPerm(arr, perm) { return perm.map((i) => arr[i]); }
320
+ function invertPerm(perm) {
321
+ const inv = new Array(perm.length);
322
+ perm.forEach((p, i) => { inv[p] = i; });
323
+ return inv;
324
+ }
325
+
326
+ /** ENCODE → GameTank Game Genie. `address` 0..0xFFFF, `value` 0..0xFF, optional
327
+ * `compare` 0..0xFF. Returns the dashed letter code, or null if out of range. */
328
+ export function encodeGameTankGameGenie({ address, value, compare }) {
329
+ if (address == null || value == null) return null;
330
+ const a = address & 0xFFFF, v = value & 0xFF;
331
+ if (address < 0 || address > 0xFFFF || value < 0 || value > 0xFF) return null;
332
+
333
+ // payload nibbles, MSB-first: addr[15:12],addr[11:8],addr[7:4],addr[3:0],val[7:4],val[3:0]
334
+ let payload = [(a >> 12) & 0xF, (a >> 8) & 0xF, (a >> 4) & 0xF, a & 0xF, (v >> 4) & 0xF, v & 0xF];
335
+ let perm = GT_PERM6;
336
+ if (compare != null) {
337
+ if (compare < 0 || compare > 0xFF) return null;
338
+ const c = compare & 0xFF;
339
+ payload = payload.concat([(c >> 4) & 0xF, c & 0xF]);
340
+ perm = GT_PERM8;
341
+ }
342
+ const scrambled = applyPerm(payload, perm);
343
+ const sum = gtChecksum(payload);
344
+ const nibs = scrambled.concat([sum]);
345
+ const letters = gtNibblesToLetters(nibs);
346
+ // dash after the first 3 (plain) / 4 (compare) letters for readability
347
+ const cut = compare != null ? 4 : 3;
348
+ return letters.slice(0, cut) + "-" + letters.slice(cut);
349
+ }
350
+
351
+ /** Decode a GameTank Game Genie code → { address, value, compare? } or null. */
352
+ export function decodeGameTankGameGenie(code) {
353
+ const n = gtLettersToNibbles(code);
354
+ if (!n) return null;
355
+ let perm, payloadLen;
356
+ if (n.length === 7) { perm = GT_PERM6; payloadLen = 6; }
357
+ else if (n.length === 9) { perm = GT_PERM8; payloadLen = 8; }
358
+ else return null;
359
+
360
+ const scrambled = n.slice(0, payloadLen);
361
+ const sum = n[payloadLen];
362
+ const payload = applyPerm(scrambled, invertPerm(perm));
363
+ if (gtChecksum(payload) !== sum) return null; // typo / not a valid GameTank code
364
+
365
+ const address = ((payload[0] << 12) | (payload[1] << 8) | (payload[2] << 4) | payload[3]) & 0xFFFF;
366
+ const value = ((payload[4] << 4) | payload[5]) & 0xFF;
367
+ if (payloadLen === 6) return { address, value };
368
+ const compare = ((payload[6] << 4) | payload[7]) & 0xFF;
369
+ return { address, value, compare };
370
+ }
371
+
280
372
  // ── Game Boy GameShark ──────────────────────────────────────────────────
281
373
  // 8 hex digits "TTVVAAAA": TT = type/RAM-bank byte, VV = replacement value,
282
374
  // AAAA = the RAM address in LITTLE-ENDIAN order. (Distinct from GB Game Genie,
@@ -340,6 +432,11 @@ export function detectDevice(code, platform) {
340
432
  if (hex8 || hyphenHex) return "action-replay"; // SMS/GG Action Replay
341
433
  if (/^[0-9A-F]{3}-[0-9A-F]{3}/i.test(c)) return "game-genie";
342
434
  return "unknown";
435
+ case "gametank":
436
+ // 7-letter (XXX-XXXX) or 9-letter (XXXX-XXXXX) GameTank wheel code.
437
+ if (/^[KLMNPQRSTVWXYZ23]{3}-[KLMNPQRSTVWXYZ23]{4}$/i.test(c) ||
438
+ /^[KLMNPQRSTVWXYZ23]{4}-[KLMNPQRSTVWXYZ23]{5}$/i.test(c)) return "game-genie";
439
+ return "unknown";
343
440
  default:
344
441
  return "unknown";
345
442
  }
@@ -370,6 +467,8 @@ export function decodeCode(code, platform) {
370
467
  case "gg":
371
468
  // SMS/GG Action Replay raw-hex (addr/val, no scramble).
372
469
  return decodeProActionReplay(c, platform);
470
+ case "gametank":
471
+ return decodeGameTankGameGenie(c);
373
472
  default:
374
473
  return null;
375
474
  }
@@ -476,6 +575,7 @@ export function nativeDevicesFor(platform) {
476
575
  case "snes": return ["pro-action-replay", "game-genie"];
477
576
  case "gb": case "gbc": return ["game-genie", "gameshark"];
478
577
  case "sms": case "gg": return ["action-replay"];
578
+ case "gametank": return ["game-genie"];
479
579
  default: return ["raw"];
480
580
  }
481
581
  }
@@ -494,6 +594,7 @@ export function encodeForDevice({ address, value, compare }, platform, device) {
494
594
  else if (["genesis", "megadrive", "md"].includes(platform)) code = encodeGenesisGameGenie({ address, value });
495
595
  else if (["gb", "gbc"].includes(platform)) code = encodeGbGameGenie({ address, value, compare });
496
596
  else if (platform === "snes") code = encodeSnesGameGenie({ address, value });
597
+ else if (platform === "gametank") code = encodeGameTankGameGenie({ address, value, compare });
497
598
  return code ? { code, device: "game-genie" } : null;
498
599
  }
499
600
  case "pro-action-replay": {
@@ -0,0 +1,178 @@
1
+ // JsGameHost — adapts jsgamelauncher's (rungame) headless host session to the subset
2
+ // of the LibretroHost surface romdev's shared tools drive.
3
+ //
4
+ // A jsgame is a JS web game run in a sandboxed V8 realm (no emulation). rungame's
5
+ // createHostSession() gives us a window-less, host-stepped session: we pump the game's
6
+ // requestAnimationFrame one frame at a time and read the offscreen canvas back as RGBA.
7
+ // So this host implements RUN + SEE + DRIVE (loadMedia / stepFrames / getFramebuffer /
8
+ // screenshot / setInput / status) but NOT the emulator-only surface (memory regions,
9
+ // cpuState, disasm, cheats). The V8 bonus here is JS-heap/globals introspection.
10
+ //
11
+ // REQUIRES the process to run with --experimental-vm-modules (rungame's realm uses
12
+ // vm.SourceTextModule). loadMedia throws a clear message if it's missing.
13
+
14
+ import { createHostSession } from "rungame";
15
+ import { framebufferToScreenshot, framebufferToRgba } from "./framebuffer.js";
16
+ import { ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
17
+
18
+ // canvas.data() is RGBA already — reuse romdev's RGBA framebuffer format (no decode).
19
+ const JSGAME_FB_FORMAT = ROMDEV_PIXEL_FORMAT_RGBA8888;
20
+
21
+ const DEFAULT_W = 640;
22
+ const DEFAULT_H = 480;
23
+
24
+ export class JsGameHost {
25
+ constructor({ log } = {}) {
26
+ this.kind = "jsgame";
27
+ this.hwRender = null;
28
+ this._log = log || (() => {});
29
+ this.session = null;
30
+ this.state = { lastFrame: null };
31
+ this._inputPorts = [{}];
32
+ this.status = {
33
+ loaded: false,
34
+ platform: null,
35
+ mediaPath: null,
36
+ mediaKind: "jsgame",
37
+ frameCount: 0,
38
+ fbWidth: 0,
39
+ fbHeight: 0,
40
+ coreFps: 60,
41
+ displayAspect: 0,
42
+ audioSampleRate: 0,
43
+ };
44
+ }
45
+
46
+ getCapabilities() {
47
+ return {
48
+ kind: "jsgame",
49
+ canStepFrames: true,
50
+ canScreenshot: true,
51
+ canSetInput: true,
52
+ hasAudio: false, // audio plays through the game's own WebAudio graph; not captured here yet
53
+ hasSaveData: false,
54
+ hasMemoryRegions: false,
55
+ // Runs in real V8 — JS heap/globals introspection (globalThis._jsg, the realm)
56
+ // is the bonus axis an emulator can't offer. Exposed via jsGlobals()/jsEval hooks.
57
+ hasJsIntrospection: true,
58
+ hasCpuState: false,
59
+ hasDisasm: false,
60
+ hasCheats: false,
61
+ hasWatchpoints: false,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Load a jsgame (dir / .jsg / .jsgame). `mediaPath` only — a jsgame is a directory
67
+ * tree / archive on disk, not in-memory bytes. Async: settles initial asset loads +
68
+ * renders a first frame so screenshot works immediately.
69
+ */
70
+ async loadMedia({ platform, path: mediaPath, width, height } = {}) {
71
+ if (!mediaPath) throw new Error("JsGameHost.loadMedia: provide `path` (a jsgame dir/.jsgame). In-memory bytes aren't supported for jsgame.");
72
+ if (typeof globalThis.vm?.SourceTextModule === "undefined") {
73
+ // createHostSession will throw its own clear message if the flag is missing;
74
+ // surface it early with the fix.
75
+ }
76
+ try {
77
+ this.session = await createHostSession(mediaPath, {
78
+ width: width || DEFAULT_W,
79
+ height: height || DEFAULT_H,
80
+ });
81
+ } catch (e) {
82
+ if (String(e.message).includes("SourceTextModule")) {
83
+ throw new Error(
84
+ "jsgame requires the romdev server to run with node --experimental-vm-modules " +
85
+ "(rungame sandboxes games in a vm realm). Restart the server with that flag. " +
86
+ "Original: " + e.message,
87
+ );
88
+ }
89
+ throw e;
90
+ }
91
+
92
+ this.status.loaded = true;
93
+ this.status.platform = platform || "jsgame";
94
+ this.status.mediaPath = mediaPath;
95
+ this.status.mediaKind = "jsgame";
96
+ this.status.frameCount = 0;
97
+
98
+ // A first frame so status + screenshot are valid immediately (createHostSession
99
+ // already settled initial async loads).
100
+ await this.stepFrames(1);
101
+ return this.status;
102
+ }
103
+
104
+ /** Map romdev's setInput vocabulary → a jsgame standard-pad object. */
105
+ _padFromInput(input) {
106
+ if (!input || typeof input !== "object") return {};
107
+ // Pass through the standard button names jsgame's synthetic pad understands
108
+ // (a,b,x,y,l1,r1,l2,r2,select,start,l3,r3,up,down,left,right + axes lx/ly/rx/ry).
109
+ return { ...input };
110
+ }
111
+
112
+ setInput(input) {
113
+ if (input && Array.isArray(input.ports)) {
114
+ this._inputPorts = input.ports.map((p) => this._padFromInput(p));
115
+ } else {
116
+ this._inputPorts = [this._padFromInput(input)];
117
+ }
118
+ if (this.session) this.session.setInput(this._inputPorts);
119
+ }
120
+
121
+ /**
122
+ * Advance n frames. stepFrame is async (it yields so the game's async work settles),
123
+ * so this returns a Promise — the tools await it (LibretroHost.stepFrames is sync, but
124
+ * the frame tool already awaits host.stepFrames for proxied cores).
125
+ */
126
+ async stepFrames(n) {
127
+ if (!this.session) throw new Error("no jsgame loaded — loadMedia first");
128
+ this.session.setInput(this._inputPorts);
129
+ for (let i = 0; i < n; i++) {
130
+ await this.session.stepFrame();
131
+ this.status.frameCount++;
132
+ }
133
+ const f = this.session.readFrame(); // { data (RGBA), width, height }
134
+ this.state.lastFrame = {
135
+ width: f.width,
136
+ height: f.height,
137
+ pixels: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data.buffer || f.data),
138
+ pitch: f.width * 4,
139
+ format: JSGAME_FB_FORMAT,
140
+ };
141
+ this.status.fbWidth = f.width;
142
+ this.status.fbHeight = f.height;
143
+ return n;
144
+ }
145
+
146
+ getFramebuffer() {
147
+ if (!this.state.lastFrame) throw new Error("no frame produced yet — step frames first");
148
+ return this.state.lastFrame;
149
+ }
150
+
151
+ screenshot() {
152
+ const f = this.getFramebuffer();
153
+ return framebufferToScreenshot(f.width, f.height, f.pixels, f.pitch, f.format);
154
+ }
155
+
156
+ screenshotRgba() {
157
+ const f = this.getFramebuffer();
158
+ return { width: f.width, height: f.height,
159
+ pixels: framebufferToRgba(f.width, f.height, f.pixels, f.pitch, f.format) };
160
+ }
161
+
162
+ // ── JS introspection (the V8-runtime bonus) ──────────────────────────────────
163
+ /** The game's exposed globals bag (rungame sets globalThis._jsg = {controllers, rom, …}). */
164
+ jsGlobals() {
165
+ return globalThis._jsg ? Object.keys(globalThis._jsg) : [];
166
+ }
167
+
168
+ cheatsSupported() { return false; }
169
+
170
+ reset() { this.status.frameCount = 0; }
171
+
172
+ destroy() {
173
+ if (this.session) { try { this.session.destroy(); } catch { /* ignore */ } }
174
+ this.session = null;
175
+ this.state.lastFrame = null;
176
+ this.status.loaded = false;
177
+ }
178
+ }
@@ -214,6 +214,11 @@ export const PLATFORM_VIRTUAL_EXT = {
214
214
  msx: ".rom",
215
215
  };
216
216
  import { RETRO_DEVICE_JOYPAD, ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
217
+ import { decodeCode as decodeCheatCode } from "../cheats/gamegenie.js";
218
+
219
+ // Platforms whose core stubs retro_cheat_set but ships romdev's value-override cheat
220
+ // device (romdev_cheat_set) — cheats route through that read-substitution instead.
221
+ const CHEAT_PREFER_ROMDEV_DEVICE = new Set(["gametank"]);
217
222
 
218
223
  // C64 controller→keyboard map (the Batocera/RetroDeck model: a CONTROLLER alone
219
224
  // plays C64 — no physical keyboard needed — by mapping spare buttons/stick to
@@ -1536,7 +1541,18 @@ export class LibretroHost {
1536
1541
  * (Older bundled cores predate the cheat-export build flag.) */
1537
1542
  cheatsSupported() {
1538
1543
  const mod = this.mod;
1539
- return !!(mod && typeof mod._retro_cheat_set === "function");
1544
+ // Real cheat support = EITHER the core's retro_cheat_set actually applies codes,
1545
+ // OR romdev's value-override cheat device (romdev_cheat_set) is present. Some cores
1546
+ // (GameTank) stub retro_cheat_set but ship the romdev_cheat_* read-substitution
1547
+ // device — that's the working path for them.
1548
+ return !!(mod && (typeof mod._romdev_cheat_set === "function" || typeof mod._retro_cheat_set === "function"));
1549
+ }
1550
+
1551
+ /** True if this core applies cheats via romdev's value-override device (romdev_cheat_set)
1552
+ * rather than the libretro retro_cheat_set (which is a stub on some cores). */
1553
+ _usesRomdevCheatDevice() {
1554
+ return !!(this.mod && typeof this.mod._romdev_cheat_set === "function" &&
1555
+ CHEAT_PREFER_ROMDEV_DEVICE.has(this.status.platform));
1540
1556
  }
1541
1557
 
1542
1558
  /**
@@ -1553,6 +1569,32 @@ export class LibretroHost {
1553
1569
  */
1554
1570
  setCheat(index, code, enabled = true) {
1555
1571
  const mod = this._needMod();
1572
+
1573
+ // romdev value-override device (GameTank etc.): the core's retro_cheat_set is a
1574
+ // stub, but romdev_cheat_set installs a hardware-faithful read substitution. Decode
1575
+ // the code to {address,value,compare} and hand it to the device.
1576
+ if (this._usesRomdevCheatDevice()) {
1577
+ const decoded = decodeCheatCode(String(code), this.status.platform);
1578
+ if (!decoded || decoded.address == null || decoded.value == null) {
1579
+ throw new Error(
1580
+ `setCheat: could not decode '${code}' for ${this.status.platform}. ` +
1581
+ `Provide a GameTank Game Genie code or a raw ADDR:VAL[:COMPARE].`,
1582
+ );
1583
+ }
1584
+ mod._romdev_cheat_set(
1585
+ index >>> 0,
1586
+ decoded.address & 0xFFFF,
1587
+ decoded.value & 0xFF,
1588
+ (decoded.compare ?? 0) & 0xFF,
1589
+ decoded.compare != null ? 1 : 0,
1590
+ enabled ? 1 : 0,
1591
+ );
1592
+ if (!this._activeCheats) this._activeCheats = new Map();
1593
+ if (enabled) this._activeCheats.set(index, code);
1594
+ else this._activeCheats.delete(index);
1595
+ return;
1596
+ }
1597
+
1556
1598
  if (typeof mod._retro_cheat_set !== "function") {
1557
1599
  throw new Error(
1558
1600
  "this core build does not expose the cheat interface (retro_cheat_set). " +
@@ -0,0 +1,263 @@
1
+ // WasmcartHost — adapts wasmcart's CartHost to the subset of the LibretroHost
2
+ // surface that romdev's shared tools (frame/input/playtest/livestream) drive.
3
+ //
4
+ // wasmcart carts aren't emulated CPUs — they're native WASM game modules that
5
+ // export wc_render() and draw into a framebuffer. So this host implements the
6
+ // RUN + SEE + DRIVE surface (loadMedia / stepFrames / getFramebuffer / screenshot
7
+ // / setInput / status) but NOT the emulator-only surface (readMemory regions,
8
+ // cpuState, watchpoints, disasm, cheats). Those tools consult getCapabilities()
9
+ // and refuse with a pointer rather than pretending.
10
+ //
11
+ // CartHost.runFrame(pads) is a pure step function returning
12
+ // { framebuffer: Uint8Array (XRGB8888), width, height, audio }
13
+ // which maps cleanly onto stepFrames + state.lastFrame. We drive its fixed-step
14
+ // clock so frame N is reproducible (setFixedStep), matching how romdev steps a core.
15
+
16
+ import { CartHost } from "wasmcart";
17
+ import { framebufferToScreenshot, framebufferToRgba } from "./framebuffer.js";
18
+ import { RETRO_PIXEL_FORMAT_XRGB8888 } from "./retroConstants.js";
19
+
20
+ // wasmcart framebuffer is uint32 XRGB8888 (0x00RRGGBB) → bytes [B,G,R,X] in LE
21
+ // memory, identical to libretro's XRGB8888, so romdev's decoder handles it as-is.
22
+ const WASMCART_FB_FORMAT = RETRO_PIXEL_FORMAT_XRGB8888;
23
+
24
+ // libretro RETRO_DEVICE_ID_JOYPAD bit → wasmcart pad button field. romdev's
25
+ // setInput speaks the libretro button vocabulary (b,y,select,start,up,down,left,
26
+ // right,a,x,l,r,…); wasmcart pads carry the same conceptual buttons. We translate
27
+ // the input bitmask/object into the pad object shape CartHost._writePads expects.
28
+ const JOYPAD = {
29
+ b: 0, y: 1, select: 2, start: 3, up: 4, down: 5, left: 6, right: 7,
30
+ a: 8, x: 9, l: 10, r: 11, l2: 12, r2: 13, l3: 14, r3: 15,
31
+ };
32
+
33
+ export class WasmcartHost {
34
+ constructor({ log } = {}) {
35
+ this.kind = "wasmcart";
36
+ this.hwRender = null; // wasmcart GL carts render into their own FB; no libretro HW-render path
37
+ this._log = log || (() => {});
38
+ this.cart = null;
39
+ this.state = { lastFrame: null };
40
+ this._inputPorts = [{}]; // per-port pad objects, applied each stepFrames
41
+ this.status = {
42
+ loaded: false,
43
+ platform: null,
44
+ mediaPath: null,
45
+ mediaKind: "cart",
46
+ frameCount: 0,
47
+ fbWidth: 0,
48
+ fbHeight: 0,
49
+ coreFps: 60,
50
+ displayAspect: 0,
51
+ audioSampleRate: 0,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Capability descriptor. The shared tools branch on this; emulator-only tools
57
+ * see the false flags and refuse with a clear pointer instead of no-oping.
58
+ */
59
+ getCapabilities() {
60
+ return {
61
+ kind: "wasmcart",
62
+ canStepFrames: true,
63
+ canScreenshot: true,
64
+ canSetInput: true,
65
+ hasAudio: true,
66
+ hasSaveData: true,
67
+ // No EMULATED memory regions (there's no CPU/address-space to name), but the
68
+ // cart runs in real V8 — so we DO expose the WASM linear memory + exports for
69
+ // introspection an emulator can't give: peek the actual cart heap, list the
70
+ // module's exported functions/globals. Different axis than emulator regions.
71
+ hasMemoryRegions: false,
72
+ hasWasmIntrospection: true,
73
+ hasCpuState: false,
74
+ hasDisasm: false,
75
+ hasCheats: false,
76
+ hasWatchpoints: false,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Load a .wasc cart (or dev directory). `mediaPath` is a real path; `bytes` is
82
+ * in-memory cart zip. Mirrors LibretroHost.loadMedia's post-conditions:
83
+ * status.loaded + a first framebuffer so screenshot works immediately.
84
+ */
85
+ async loadMedia({ platform, path: mediaPath, bytes, glBackend } = {}) {
86
+ const source = bytes ?? mediaPath;
87
+ if (!source) throw new Error("WasmcartHost.loadMedia: provide `path` or `bytes`.");
88
+
89
+ this.cart = new CartHost();
90
+ await this.cart.load(source, glBackend ? { glBackend } : {});
91
+ // Deterministic clock: romdev steps frames, so frame N should be reproducible.
92
+ // Feature-detect — setFixedStep is a newer CartHost addition; older published
93
+ // versions fall back to wall-clock (still works, just non-deterministic timing).
94
+ if (typeof this.cart.setFixedStep === "function") {
95
+ this.cart.setFixedStep(1000 / 60);
96
+ this._deterministicClock = true;
97
+ }
98
+
99
+ this.status.loaded = true;
100
+ this.status.platform = platform || "wasmcart";
101
+ this.status.mediaPath = typeof mediaPath === "string" ? mediaPath : null;
102
+ this.status.mediaKind = "cart";
103
+ this.status.frameCount = 0;
104
+ this.status.coreFps = 60;
105
+
106
+ // Settle a first frame so width/height and a framebuffer exist (carts often
107
+ // finalize their resolution during the first render).
108
+ this.stepFrames(1);
109
+ this.status.fbWidth = this.state.lastFrame?.width || 0;
110
+ this.status.fbHeight = this.state.lastFrame?.height || 0;
111
+ return this.status;
112
+ }
113
+
114
+ /** Translate romdev's setInput vocabulary into a wasmcart pad object. */
115
+ _padFromInput(input) {
116
+ if (!input || typeof input !== "object") return {};
117
+ const pad = { up: 0, down: 0, left: 0, right: 0, a: 0, b: 0, x: 0, y: 0,
118
+ l: 0, r: 0, start: 0, select: 0 };
119
+ for (const key of Object.keys(pad)) {
120
+ if (input[key]) pad[key] = 1;
121
+ }
122
+ return pad;
123
+ }
124
+
125
+ /**
126
+ * Store input to apply on subsequent stepFrames. Accepts romdev's setInput shapes:
127
+ * a flat button object ({a:true,right:true}) OR the multi-port form
128
+ * ({ports:[{...p0}, {...p1}]}). wasmcart pads are per-controller, so we keep all
129
+ * ports and hand them to CartHost.runFrame.
130
+ */
131
+ setInput(input) {
132
+ if (input && Array.isArray(input.ports)) {
133
+ this._inputPorts = input.ports.map((p) => this._padFromInput(p));
134
+ } else {
135
+ this._inputPorts = [this._padFromInput(input)];
136
+ }
137
+ }
138
+
139
+ /** Advance n frames, driving CartHost.runFrame with the current input. */
140
+ stepFrames(n) {
141
+ if (!this.cart) throw new Error("no cart loaded — loadMedia first");
142
+ let r = null;
143
+ for (let i = 0; i < n; i++) {
144
+ r = this.cart.runFrame(this._inputPorts);
145
+ this.status.frameCount++;
146
+ }
147
+ if (r) {
148
+ // Copy the framebuffer view out (CartHost returns a subarray into WASM memory,
149
+ // which can move on the next frame). pitch = width*4 (no row padding).
150
+ this.state.lastFrame = {
151
+ width: r.width,
152
+ height: r.height,
153
+ pixels: new Uint8Array(r.framebuffer), // copy
154
+ pitch: r.width * 4,
155
+ format: WASMCART_FB_FORMAT,
156
+ };
157
+ this.state.lastAudio = r.audio || null;
158
+ this.status.fbWidth = r.width;
159
+ this.status.fbHeight = r.height;
160
+ }
161
+ return n;
162
+ }
163
+
164
+ /** @returns {{width,height,pixels,pitch,format}} the last rendered frame. */
165
+ getFramebuffer() {
166
+ if (!this.state.lastFrame) throw new Error("no frame produced yet — step frames first");
167
+ return this.state.lastFrame;
168
+ }
169
+
170
+ /** PNG (base64) of the current frame — same output as LibretroHost.screenshot(). */
171
+ screenshot() {
172
+ const f = this.getFramebuffer();
173
+ return framebufferToScreenshot(f.width, f.height, f.pixels, f.pitch, f.format);
174
+ }
175
+
176
+ /** Flat RGBA Uint8Array of the current frame (for the livestream/side-by-side). */
177
+ screenshotRgba() {
178
+ const f = this.getFramebuffer();
179
+ return { width: f.width, height: f.height,
180
+ pixels: framebufferToRgba(f.width, f.height, f.pixels, f.pitch, f.format) };
181
+ }
182
+
183
+ /** Save data (SRAM equivalent) if the cart declares any. */
184
+ getSaveData() {
185
+ return this.cart ? this.cart.getSaveData() : null;
186
+ }
187
+
188
+ // ── WASM introspection (the V8-runtime bonus an emulator can't offer) ─────────
189
+ //
190
+ // A wasmcart runs as a real WebAssembly instance in V8, so we can read its actual
191
+ // linear memory and enumerate its exports. This is NOT an emulated address space
192
+ // with named regions — it's the cart's own heap. `readMemory` therefore takes a
193
+ // raw byte offset into that heap (no region arg), and `wasmExports` lists what the
194
+ // module exposes.
195
+
196
+ /** Total size (bytes) of the cart's WASM linear memory. */
197
+ wasmMemorySize() {
198
+ if (!this.cart?.memory) return 0;
199
+ return this.cart.memory.buffer.byteLength;
200
+ }
201
+
202
+ /**
203
+ * Read `length` bytes from the cart's WASM linear memory at byte `offset`.
204
+ * @returns {Uint8Array} a copy (the heap can move on the next frame).
205
+ */
206
+ readMemory(offset, length) {
207
+ if (!this.cart?.memory) throw new Error("no cart loaded — loadMedia first");
208
+ const heap = new Uint8Array(this.cart.memory.buffer);
209
+ const off = offset >>> 0;
210
+ const end = Math.min(heap.length, off + (length >>> 0));
211
+ if (off >= heap.length) {
212
+ throw new Error(`offset ${off} is past the ${heap.length}-byte WASM heap`);
213
+ }
214
+ return heap.slice(off, end);
215
+ }
216
+
217
+ /**
218
+ * Write bytes into the cart's WASM linear memory at byte `offset`. Useful for
219
+ * poking cart state during debugging (there are no cheats/watchpoints, but the
220
+ * heap is real and writable).
221
+ */
222
+ writeMemory(offset, bytes) {
223
+ if (!this.cart?.memory) throw new Error("no cart loaded — loadMedia first");
224
+ const heap = new Uint8Array(this.cart.memory.buffer);
225
+ heap.set(bytes, offset >>> 0);
226
+ return bytes.length;
227
+ }
228
+
229
+ /** Enumerate the cart module's WASM exports (function/memory/global/table names + kinds). */
230
+ wasmExports() {
231
+ if (!this.cart?.instance) return [];
232
+ const ex = this.cart.instance.exports;
233
+ return Object.keys(ex).map((name) => ({
234
+ name,
235
+ kind:
236
+ typeof ex[name] === "function" ? "function"
237
+ : ex[name] instanceof WebAssembly.Memory ? "memory"
238
+ : ex[name] instanceof WebAssembly.Global ? "global"
239
+ : ex[name] instanceof WebAssembly.Table ? "table"
240
+ : typeof ex[name],
241
+ ...(ex[name] instanceof WebAssembly.Global ? { value: ex[name].value } : {}),
242
+ }));
243
+ }
244
+
245
+ /** The cart's parsed WCInfo (fbPtr, savePtr/saveSize, width/height, abi). */
246
+ getInfo() {
247
+ return this.cart ? this.cart.getInfo() : null;
248
+ }
249
+
250
+ cheatsSupported() { return false; }
251
+
252
+ reset() {
253
+ // Re-load would be needed for a true reset; expose frameCount reset as a soft reset hook.
254
+ this.status.frameCount = 0;
255
+ }
256
+
257
+ destroy() {
258
+ if (this.cart) { try { this.cart.destroy(); } catch { /* ignore */ } }
259
+ this.cart = null;
260
+ this.state.lastFrame = null;
261
+ this.status.loaded = false;
262
+ }
263
+ }
package/src/mcp/server.js CHANGED
@@ -1,4 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ // Self-re-exec with --experimental-vm-modules if it's missing. The jsgame host kind
3
+ // (rungame) sandboxes games in a vm.SourceTextModule realm, which requires that flag.
4
+ // It's harmless for everything else, so we enable it once up front rather than making
5
+ // users remember it. Guard against a re-exec loop via a sentinel env var.
6
+ if (
7
+ !process.execArgv.some((a) => a.includes("experimental-vm-modules")) &&
8
+ !process.env.ROMDEV_REEXEChild
9
+ ) {
10
+ const { spawnSync } = await import("node:child_process");
11
+ const r = spawnSync(
12
+ process.execPath,
13
+ ["--experimental-vm-modules", ...process.execArgv, process.argv[1], ...process.argv.slice(2)],
14
+ { stdio: "inherit", env: { ...process.env, ROMDEV_REEXEChild: "1" } },
15
+ );
16
+ process.exit(r.status ?? 0);
17
+ }
18
+
2
19
  // romdev — MCP server (Streamable HTTP).
3
20
  //
4
21
  // Exposes the libretro harness, save state, memory inspection, screenshot,
package/src/mcp/state.js CHANGED
@@ -122,15 +122,39 @@ export function getHostOrNull(sessionKey) {
122
122
  * @returns {LibretroHost}
123
123
  */
124
124
  export function resetHost(sessionKey) {
125
- const existing = hosts.get(sessionKey);
126
- if (existing && existing.status.loaded) {
127
- try { existing.unloadMedia(); } catch {}
128
- }
125
+ teardownHost(hosts.get(sessionKey));
129
126
  const fresh = new LibretroHost();
130
127
  hosts.set(sessionKey, fresh);
131
128
  return fresh;
132
129
  }
133
130
 
131
+ /** Tear down whatever host kind is present (LibretroHost.unloadMedia or a native
132
+ * host's destroy) — WasmcartHost/JsGameHost don't have unloadMedia. */
133
+ function teardownHost(existing) {
134
+ if (!existing) return;
135
+ try {
136
+ if (typeof existing.unloadMedia === "function" && existing.status?.loaded) {
137
+ existing.unloadMedia();
138
+ } else if (typeof existing.destroy === "function") {
139
+ existing.destroy();
140
+ }
141
+ } catch { /* ignore teardown errors */ }
142
+ }
143
+
144
+ /**
145
+ * Install an already-constructed host (a native-runtime kind: WasmcartHost /
146
+ * JsGameHost) as this session's active host, tearing down any previous one.
147
+ * Emulator platforms use resetHost + loadCore; native runtimes build their own
148
+ * host and hand it here.
149
+ * @param {string} sessionKey
150
+ * @param {object} hostInstance
151
+ */
152
+ export function installHost(sessionKey, hostInstance) {
153
+ teardownHost(hosts.get(sessionKey));
154
+ hosts.set(sessionKey, hostInstance);
155
+ return hostInstance;
156
+ }
157
+
134
158
  /** @param {string} sessionKey */
135
159
  export function clearHost(sessionKey) {
136
160
  const existing = hosts.get(sessionKey);
@@ -18,6 +18,9 @@ const GG_ADDR_RANGE = {
18
18
  snes: [0x008000, 0xFFFFFF], // mapped ROM ($xx:8000-$xx:FFFF); the Game Genie
19
19
  // ROM device patches here. (Pro Action Replay is
20
20
  // a RAM poke — for a ROM patch we pick GG below.)
21
+ gametank: [0x0000, 0xFFFF], // flat 16-bit CPU space. GameTank's Game Genie is a
22
+ // READ substitution on the bus, so any read address is
23
+ // valid (RAM or cart ROM). A NEW format — see gamegenie.js.
21
24
  };
22
25
 
23
26
  // The native ROM-PATCH device per platform (installs a read-intercept), as
@@ -94,6 +97,7 @@ const SUPPORTED = new Set([
94
97
  const MAKE_CHEAT_PLATFORMS = [
95
98
  "nes", "gb", "gbc", "snes", "genesis", "sms", "gg",
96
99
  "atari2600", "atari7800", "lynx", "gba", "c64", "pce", "msx",
100
+ "gametank", // NEW: GameTank Game Genie (read-substitution device) — see gamegenie.js
97
101
  ];
98
102
 
99
103
  // gameCheats indexes whose codes are predominantly ENCRYPTED at the source
@@ -76,7 +76,7 @@ const NEARLY_BLANK_DOMINANT = 0.92;
76
76
  */
77
77
  export async function computeVerify(host, frames, sessionKey) {
78
78
  const platform = host.status.platform;
79
- if (frames && frames > 0) host.stepFrames(frames);
79
+ if (frames && frames > 0) await host.stepFrames(frames);
80
80
  const frameCount = host.status.frameCount;
81
81
 
82
82
  // --- pixel content check (platform-agnostic) ---
@@ -317,7 +317,9 @@ export function compositeSideBySide(a, b, gap = 4) {
317
317
  export function registerFrameTools(server, z, sessionKey) {
318
318
  async function doStep({ frames }) {
319
319
  const host = getHost(sessionKey);
320
- const n = host.stepFrames(frames);
320
+ // await: native-runtime hosts (jsgame) have an async stepFrames that yields for
321
+ // the game's async work; awaiting a sync LibretroHost return is a harmless no-op.
322
+ const n = await host.stepFrames(frames);
321
323
  // Surface a co-drive conflict the moment the agent steps: a human
322
324
  // actively playing in the playtest window means this step raced their
323
325
  // real-time loop. Field only appears when the conflict is real.
@@ -786,7 +788,7 @@ export function registerFrameTools(server, z, sessionKey) {
786
788
  async function doStepAndShot({ frames, path: outPath, inline }) {
787
789
  requireImageTarget(outPath, inline, "frame({op:'stepAndShot'})");
788
790
  const host = getHost(sessionKey);
789
- host.stepFrames(frames);
791
+ await host.stepFrames(frames);
790
792
  const shot = host.screenshot();
791
793
  const coDrive = humanCoDriveWarning(sessionKey);
792
794
  if (!inline) {
@@ -34,6 +34,7 @@ import { registerFreeSpaceTools } from "./free-space.js";
34
34
  import { registerReinjectTools } from "./reinject.js";
35
35
  import { registerSpliceChrTools } from "./splice-chr.js";
36
36
  import { registerCartPartsTools } from "./cart-parts.js";
37
+ import { registerNativePackTools } from "./native-pack.js";
37
38
  import { registerFontMapTools } from "./font-map.js";
38
39
  import { registerDisasmTools } from "./disasm.js";
39
40
  import { registerFindReferencesTools } from "./find-references.js";
@@ -173,7 +174,7 @@ const CATEGORIES = [
173
174
  name: "assets",
174
175
  description: "Convert PNGs to platform tile formats, encode WAVs to BRR, scan ROMs to identify them.",
175
176
  useWhen: ["importing graphics or audio assets", "checking what a ROM file is"],
176
- register: (s, z, k) => { registerAssetTools(s, z, k); registerAudioTools(s, z, k); registerRomIdTools(s, z, k); registerDiffRomsTools(s, z, k); registerFreeSpaceTools(s, z, k); registerReinjectTools(s, z, k); registerSpliceChrTools(s, z, k); registerCartPartsTools(s, z, k); registerFontMapTools(s, z, k); registerArtLoaderTools(s, z, k); registerSpritePipelineTools(s, z, k); registerLospecTools(s, z, k); registerMetaSpriteTools(s, z, k); },
177
+ register: (s, z, k) => { registerAssetTools(s, z, k); registerAudioTools(s, z, k); registerRomIdTools(s, z, k); registerDiffRomsTools(s, z, k); registerFreeSpaceTools(s, z, k); registerReinjectTools(s, z, k); registerSpliceChrTools(s, z, k); registerCartPartsTools(s, z, k); registerNativePackTools(s, z, k); registerFontMapTools(s, z, k); registerArtLoaderTools(s, z, k); registerSpritePipelineTools(s, z, k); registerLospecTools(s, z, k); registerMetaSpriteTools(s, z, k); },
177
178
  },
178
179
  {
179
180
  name: "project",
@@ -1,8 +1,17 @@
1
1
  import { resolveCore } from "../../cores/registry.js";
2
2
  import {
3
3
  clearHost, clearHostB, getHost, getHostB, getHostBOrNull, getHostOrNull,
4
- rememberLastMedia, resetHost, resetHostB,
4
+ installHost, rememberLastMedia, resetHost, resetHostB,
5
5
  } from "../state.js";
6
+ import { WasmcartHost } from "../../host/WasmcartHost.js";
7
+ import { JsGameHost } from "../../host/JsGameHost.js";
8
+
9
+ // Native-runtime platforms don't use a libretro core — they run a native WASM/JS
10
+ // game module in-process. loadMedia builds the matching host and installs it.
11
+ const NATIVE_RUNTIME_HOSTS = {
12
+ wasmcart: () => new WasmcartHost(),
13
+ jsgame: () => new JsGameHost(),
14
+ };
6
15
  import { jsonContent, safeTool, textContent } from "../util.js";
7
16
  import { resolveCheatCodeForApply } from "./cheats.js";
8
17
  import { attachObserverFrame } from "./watch-memory.js";
@@ -15,10 +24,34 @@ export function registerLifecycleTools(server, z, sessionKey) {
15
24
  // it gets its own fresh host and does NOT overwrite slot A's recovery
16
25
  // breadcrumb, since B is transient scratch, not the session's main ROM.
17
26
  async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats, slot }) {
18
- const resolved = resolveCore(platform);
19
- if (!resolved) throw new Error(`no core available for platform '${platform}'`);
20
27
  if (!path && !base64) throw new Error("loadMedia: provide either `path` (file on disk) or `base64` (ROM bytes).");
21
28
  if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
29
+
30
+ // Native-runtime kinds (wasmcart, jsgame) bypass the libretro core path: build
31
+ // their own host, load the game module, install it as the session host. They
32
+ // share the frame/input/screenshot surface but not loadCore/cheats/regions.
33
+ if (NATIVE_RUNTIME_HOSTS[platform]) {
34
+ if (slot === "b") throw new Error(`slot 'b' (side-by-side) is not supported for '${platform}'`);
35
+ const host = NATIVE_RUNTIME_HOSTS[platform]();
36
+ const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
37
+ await host.loadMedia({ platform, ...(bytes ? { bytes } : { path }) });
38
+ installHost(sessionKey, host);
39
+ if (path || bytes) rememberLastMedia(sessionKey, { platform, path, fromBase64: !!bytes });
40
+ const caps = host.getCapabilities();
41
+ const result = {
42
+ loaded: true, platform, kind: caps.kind,
43
+ mediaKind: host.status.mediaKind,
44
+ ...(bytes ? { bytes: bytes.length } : { path: host.status.mediaPath }),
45
+ fbWidth: host.status.fbWidth, fbHeight: host.status.fbHeight,
46
+ capabilities: caps,
47
+ };
48
+ // Push the first frame to the /livestream observer, same as an emulator load.
49
+ attachObserverFrame(result, host, `${platform} loaded`);
50
+ return result;
51
+ }
52
+
53
+ const resolved = resolveCore(platform);
54
+ if (!resolved) throw new Error(`no core available for platform '${platform}'`);
22
55
  const slotB = slot === "b";
23
56
  const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
24
57
  await host.loadCore(resolved.jsPath, resolved.wasmPath, { hwRender: resolved.hwRender, noderawfs: resolved.noderawfs });
@@ -0,0 +1,134 @@
1
+ // native-pack — the "compile" step for the native-runtime kinds (wasmcart / jsgame).
2
+ //
3
+ // These game artifacts are ZIP archives, so packing a source directory into one is the
4
+ // build verb for them (there is NO compiler here — wasmcart is language-agnostic, bring
5
+ // your own WASM; jsgame is plain JS). romdev just assembles the archive.
6
+ //
7
+ // .wasc (wasmcart): zip of { manifest.json, cart.wasm, assets/… }. If the source dir
8
+ // lacks a manifest but has a single .wasm, a minimal manifest is generated.
9
+ // .jsgame (jsgame): zip of the game directory as-is (must contain package.json with a
10
+ // "main", or an index.html / main.js entry — same as rungame expects).
11
+
12
+ import { readFileSync, readdirSync, statSync, writeFileSync, existsSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { zipSync } from "fflate";
15
+ import { jsonContent, safeTool } from "../util.js";
16
+
17
+ /** Recursively collect files under `dir` into a { relPath: Uint8Array } map for zipSync. */
18
+ function collectDir(dir, baseDir = dir, out = {}) {
19
+ for (const name of readdirSync(dir)) {
20
+ const full = path.join(dir, name);
21
+ const rel = path.relative(baseDir, full).split(path.sep).join("/");
22
+ const st = statSync(full);
23
+ if (st.isDirectory()) collectDir(full, baseDir, out);
24
+ else out[rel] = new Uint8Array(readFileSync(full));
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /**
30
+ * Pack a wasmcart source dir (or a bare .wasm) into a .wasc archive.
31
+ * @param {object} a
32
+ * @param {string} [a.source] source directory (dev layout) to zip.
33
+ * @param {string} [a.wasm] a single cart .wasm (a manifest is generated around it).
34
+ * @param {string} [a.assets] optional assets dir (used with `wasm`).
35
+ * @param {string} a.outputPath where to write the .wasc.
36
+ * @param {string} [a.name] manifest name (generated-manifest case).
37
+ * @param {number} [a.players] manifest players count (generated-manifest case).
38
+ */
39
+ export function packWasc({ source, wasm, assets, outputPath, name, players } = {}) {
40
+ if (!outputPath) throw new Error("pack(wasmcart): `outputPath` is required.");
41
+ let files;
42
+
43
+ if (source) {
44
+ if (!existsSync(source) || !statSync(source).isDirectory()) {
45
+ throw new Error(`pack(wasmcart): source '${source}' is not a directory.`);
46
+ }
47
+ files = collectDir(source);
48
+ if (!files["manifest.json"]) {
49
+ // No manifest in the dir — synthesize one pointing at the single .wasm present.
50
+ const wasmEntry = Object.keys(files).find((f) => f.endsWith(".wasm"));
51
+ if (!wasmEntry) throw new Error("pack(wasmcart): source dir has no manifest.json and no .wasm to derive one.");
52
+ const manifest = { name: name || path.basename(outputPath, ".wasc"), version: "1.0.0", entry: wasmEntry };
53
+ if (players) manifest.players = players;
54
+ files["manifest.json"] = new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2)));
55
+ }
56
+ } else if (wasm) {
57
+ if (!existsSync(wasm)) throw new Error(`pack(wasmcart): wasm '${wasm}' not found.`);
58
+ files = { "cart.wasm": new Uint8Array(readFileSync(wasm)) };
59
+ const manifest = { name: name || path.basename(outputPath, ".wasc"), version: "1.0.0", entry: "cart.wasm" };
60
+ if (players) manifest.players = players;
61
+ if (assets) {
62
+ if (!existsSync(assets) || !statSync(assets).isDirectory()) throw new Error(`pack(wasmcart): assets '${assets}' is not a directory.`);
63
+ manifest.assets = "assets/";
64
+ const assetFiles = collectDir(assets);
65
+ for (const [rel, buf] of Object.entries(assetFiles)) files["assets/" + rel] = buf;
66
+ }
67
+ files["manifest.json"] = new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2)));
68
+ } else {
69
+ throw new Error("pack(wasmcart): provide `source` (a dir) or `wasm` (a cart .wasm).");
70
+ }
71
+
72
+ const zipped = zipSync(files, { level: 6 });
73
+ writeFileSync(outputPath, zipped);
74
+ return { outputPath, entries: Object.keys(files).length, bytes: zipped.length };
75
+ }
76
+
77
+ /**
78
+ * Pack a jsgame source dir into a .jsgame archive (a plain zip of the dir).
79
+ * @param {object} a
80
+ * @param {string} a.source the game directory (must have package.json main / index.html / main.js).
81
+ * @param {string} a.outputPath where to write the .jsgame.
82
+ */
83
+ export function packJsgame({ source, outputPath } = {}) {
84
+ if (!source || !existsSync(source) || !statSync(source).isDirectory()) {
85
+ throw new Error(`pack(jsgame): source '${source}' is not a directory.`);
86
+ }
87
+ if (!outputPath) throw new Error("pack(jsgame): `outputPath` is required.");
88
+ const files = collectDir(source);
89
+ // Sanity: a jsgame needs an entry rungame can resolve.
90
+ const hasEntry = files["package.json"] || files["index.html"] || files["main.js"] ||
91
+ Object.keys(files).some((f) => f.endsWith("/main.js") || f === "index.js");
92
+ if (!hasEntry) {
93
+ throw new Error("pack(jsgame): source has no entry (package.json 'main', index.html, or main.js). rungame won't be able to run it.");
94
+ }
95
+ const zipped = zipSync(files, { level: 6 });
96
+ writeFileSync(outputPath, zipped);
97
+ return { outputPath, entries: Object.keys(files).length, bytes: zipped.length };
98
+ }
99
+
100
+ /**
101
+ * Register the `pack` tool — the "build" verb for the native-runtime kinds. Packs a
102
+ * source directory into a distributable .wasc / .jsgame archive (a zip). NOT a compiler
103
+ * (wasmcart is any-language-to-WASM; jsgame is plain JS) — it just assembles the archive.
104
+ */
105
+ export function registerNativePackTools(server, z) {
106
+ server.tool(
107
+ "pack",
108
+ "Package a native-runtime game's source into its distributable archive (the 'build' step " +
109
+ "for wasmcart/jsgame — a ZIP, NOT a compiler). `target`:\n" +
110
+ "• 'wasc' (wasmcart) — pass `source` (a dev dir: manifest.json + cart.wasm + assets/) OR " +
111
+ "`wasm` (a single cart .wasm; a minimal manifest is generated, with optional `assets` dir). " +
112
+ "Writes a .wasc. NOTE: romdev does NOT compile WASM — bring your own (any language → wasm).\n" +
113
+ "• 'jsgame' (jsgame) — pass `source` (the game dir; must have package.json 'main', index.html, " +
114
+ "or main.js). Writes a .jsgame. Pure JS, no build needed beyond the zip.",
115
+ {
116
+ target: z.enum(["wasc", "jsgame"]).describe("wasc = wasmcart .wasc; jsgame = jsgame .jsgame."),
117
+ source: z.string().optional().describe("Source directory to pack. (wasc: a dev-layout dir; jsgame: the game dir.)"),
118
+ wasm: z.string().optional().describe("target=wasc: a single cart .wasm to wrap (a manifest is generated). Use instead of `source`."),
119
+ assets: z.string().optional().describe("target=wasc with `wasm`: an assets directory to include under assets/."),
120
+ outputPath: z.string().describe("Absolute path to write the archive (.wasc / .jsgame)."),
121
+ name: z.string().optional().describe("target=wasc: manifest game name (default derived from outputPath)."),
122
+ players: z.number().int().min(1).max(4).optional().describe("target=wasc: manifest players count."),
123
+ },
124
+ safeTool(async (args) => {
125
+ if (args.target === "wasc") {
126
+ return jsonContent(packWasc(args));
127
+ }
128
+ if (args.target === "jsgame") {
129
+ return jsonContent(packJsgame(args));
130
+ }
131
+ throw new Error(`pack: unknown target '${args.target}'.`);
132
+ }),
133
+ );
134
+ }