romdevtools 0.85.0 → 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,28 @@ 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
+
7
29
  ## 0.85.0 — 2026-07-01
8
30
 
9
31
  - **Native-runtime game kinds — wasmcart + jsgame are now first-class hosts.** romdev is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.85.0",
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",
@@ -13,8 +13,7 @@
13
13
  "postinstall": "node src/install/postinstall.mjs",
14
14
  "smoke": "node --enable-source-maps src/cli/smoke.js",
15
15
  "mcp": "node --enable-source-maps src/mcp/server.js",
16
- "test": "node --test 'src/**/*.test.js' 'test/**/*.test.js'",
17
- "test:jsgame": "node --experimental-vm-modules --test-force-exit --test test/jsgame-host.test.js"
16
+ "test": "node --test 'src/**/*.test.js' 'test/**/*.test.js'"
18
17
  },
19
18
  "keywords": [
20
19
  "mcp",
@@ -59,7 +58,7 @@
59
58
  "romdev-core-fceumm": "0.11.0",
60
59
  "romdev-core-flycast": "0.2.0",
61
60
  "romdev-core-gambatte": "0.10.0",
62
- "romdev-core-gametank": "0.1.0",
61
+ "romdev-core-gametank": "0.2.0",
63
62
  "romdev-core-geargrafx": "0.8.0",
64
63
  "romdev-core-gpgx": "0.13.0",
65
64
  "romdev-core-handy": "0.8.0",
@@ -111,5 +110,8 @@
111
110
  "homepage": "https://github.com/monteslu/romdev#readme",
112
111
  "bugs": {
113
112
  "url": "https://github.com/monteslu/romdev/issues"
113
+ },
114
+ "devDependencies": {
115
+ "rawr": "^1.0.1"
114
116
  }
115
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": {
@@ -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). " +
@@ -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