romdevtools 0.27.0 → 0.28.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/AGENTS.md +5 -3
- package/CHANGELOG.md +309 -0
- package/README.md +1 -1
- package/examples/README.md +1 -1
- package/examples/atari2600/templates/platformer.asm +18 -9
- package/examples/atari2600/templates/racing.asm +25 -4
- package/examples/atari2600/templates/shmup.asm +30 -5
- package/examples/atari2600/templates/sports.asm +41 -9
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +12 -8
- package/examples/atari7800/templates/puzzle.c +7 -4
- package/examples/atari7800/templates/racing.c +5 -2
- package/examples/atari7800/templates/shmup.c +8 -4
- package/examples/atari7800/templates/sports.c +6 -3
- package/examples/c64/templates/platformer.c +28 -24
- package/examples/c64/templates/puzzle.c +77 -16
- package/examples/c64/templates/racing.c +9 -0
- package/examples/c64/templates/shmup.c +13 -1
- package/examples/c64/templates/sports.c +9 -4
- package/examples/gb/templates/platformer.c +6 -2
- package/examples/gb/templates/puzzle.c +279 -101
- package/examples/gb/templates/racing.c +13 -1
- package/examples/gb/templates/shmup.c +13 -1
- package/examples/gb/templates/sports.c +9 -3
- package/examples/gba/templates/platformer.c +7 -13
- package/examples/gba/templates/puzzle.c +93 -15
- package/examples/gba/templates/racing.c +13 -1
- package/examples/gba/templates/shmup.c +13 -1
- package/examples/gba/templates/sports.c +17 -5
- package/examples/gbc/templates/platformer.c +6 -2
- package/examples/gbc/templates/puzzle.c +878 -178
- package/examples/gbc/templates/racing.c +13 -1
- package/examples/gbc/templates/shmup.c +13 -1
- package/examples/gbc/templates/sports.c +9 -3
- package/examples/genesis/templates/puzzle.c +76 -15
- package/examples/genesis/templates/racing.c +13 -1
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/gg/templates/platformer.c +4 -0
- package/examples/gg/templates/puzzle.c +80 -14
- package/examples/gg/templates/racing.c +17 -1
- package/examples/gg/templates/shmup.c +17 -1
- package/examples/gg/templates/sports.c +4 -0
- package/examples/lynx/templates/platformer.c +25 -6
- package/examples/lynx/templates/puzzle.c +77 -14
- package/examples/lynx/templates/shmup.c +13 -1
- package/examples/lynx/templates/sports.c +5 -2
- package/examples/msx/platformer/main.c +2 -0
- package/examples/msx/puzzle/main.c +78 -15
- package/examples/msx/racing/main.c +1 -0
- package/examples/msx/shmup/main.c +1 -0
- package/examples/msx/sports/main.c +3 -2
- package/examples/nes/templates/platformer.c +11 -3
- package/examples/nes/templates/puzzle.c +81 -21
- package/examples/nes/templates/racing.c +15 -1
- package/examples/nes/templates/shmup.c +1 -0
- package/examples/nes/templates/sports.c +1 -0
- package/examples/pce/platformer/main.c +3 -1
- package/examples/pce/puzzle/main.c +78 -12
- package/examples/pce/racing/main.c +1 -0
- package/examples/pce/shmup/main.c +5 -4
- package/examples/pce/sports/main.c +4 -3
- package/examples/sms/templates/platformer.c +4 -0
- package/examples/sms/templates/puzzle.c +80 -14
- package/examples/sms/templates/racing.c +17 -1
- package/examples/sms/templates/shmup.c +17 -1
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +4 -0
- package/examples/snes/templates/platformer.c +32 -15
- package/examples/snes/templates/puzzle.c +84 -16
- package/examples/snes/templates/racing.c +20 -1
- package/examples/snes/templates/shmup.c +20 -2
- package/examples/snes/templates/sports.c +7 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +245 -10
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/disasm-rebuild.js +315 -65
- package/src/mcp/tools/disasm.js +149 -28
- package/src/mcp/tools/find-references.js +216 -51
- package/src/mcp/tools/frame.js +11 -4
- package/src/mcp/tools/index.js +15 -1
- package/src/mcp/tools/input.js +26 -3
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/project.js +35 -9
- package/src/mcp/tools/toolchain.js +43 -10
- package/src/mcp/tools/watch-memory.js +141 -24
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
- package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
- package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
- package/src/platforms/gb/MENTAL_MODEL.md +16 -1
- package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
- package/src/platforms/gb/lib/c/patch-header.js +7 -4
- package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/patch-header.js +7 -4
- package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
- package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
- package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
- package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
- package/src/platforms/msx/lib/c/msx_hw.h +2 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
- package/src/platforms/nes/MENTAL_MODEL.md +10 -3
- package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
- package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
- package/src/platforms/pce/MENTAL_MODEL.md +9 -0
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +2 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/sms/MENTAL_MODEL.md +5 -0
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
- package/src/platforms/snes/MENTAL_MODEL.md +5 -0
- package/src/playtest/playtest.js +73 -3
- package/src/toolchains/index.js +37 -8
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -1105,6 +1105,7 @@ function absolutizeBuild(build, outputDir) {
|
|
|
1105
1105
|
out[key] = m;
|
|
1106
1106
|
}
|
|
1107
1107
|
}
|
|
1108
|
+
if (out.linkerConfigPath) out.linkerConfigPath = nodePath.join(outputDir, out.linkerConfigPath);
|
|
1108
1109
|
return out;
|
|
1109
1110
|
}
|
|
1110
1111
|
|
|
@@ -1148,12 +1149,16 @@ export function registerDisasmTools(server, z) {
|
|
|
1148
1149
|
"`endAddress`/`untilReturn` for one routine, `dataRanges` to mark non-code, `bank` for a banked slot. " +
|
|
1149
1150
|
"pce/msx are 'project'-only here — 'rom' doesn't map them yet.\n" +
|
|
1150
1151
|
"'project' = turn a ROM into a complete re-buildable disassembly in one call across all systems; splits into " +
|
|
1151
|
-
"regions
|
|
1152
|
-
"
|
|
1152
|
+
"regions (PER-BANK on every banked format: NES mappers, SNES LoROM, GB MBC, Sega-mapper SMS/GG, MSX megaROM, " +
|
|
1153
|
+
"2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards), REASSEMBLES each and verifies BYTE-EXACT (`roundTripOk`); " +
|
|
1154
|
+
"non-faithful lines fall back to `.byte` so it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data. " +
|
|
1155
|
+
"NES/C64/7800/Lynx/PCE ship a one-call `build()` rebuild in rebuild.json (flat AND banked); the rest ship a " +
|
|
1156
|
+
"proven native recipe in BUILD.md. (SNES 65816 usually lands at the byte-exact " +
|
|
1153
1157
|
"data-only floor — its `.a8/.i8` width state desyncs when instructions and pinned `.byte` mix.)\n" +
|
|
1154
1158
|
"'references' = scan a ROM's code for operands matching a CPU `address` and classify each (call/jump/branch/" +
|
|
1155
|
-
"read/write); also walks the vector table.
|
|
1156
|
-
"
|
|
1159
|
+
"read/write); also walks the vector table. Banked carts are scanned PER BANK (all of the formats above) — " +
|
|
1160
|
+
"refs carry `prgBank` (NES) / `romBank` (everything else). LIMITATION: direct addressing only " +
|
|
1161
|
+
"(indirect/computed jumps are missed).",
|
|
1157
1162
|
{
|
|
1158
1163
|
target: z.enum(["bytes", "rom", "project", "references"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = find refs to an address."),
|
|
1159
1164
|
// shared
|
|
@@ -1314,9 +1319,26 @@ function planRegions(platform, data) {
|
|
|
1314
1319
|
return regions;
|
|
1315
1320
|
}
|
|
1316
1321
|
if (platform === "sms" || platform === "gg") {
|
|
1317
|
-
// Z80 mapped at $0000
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1322
|
+
// Z80 mapped at $0000. ≤48KB fits the three slots flat — one region.
|
|
1323
|
+
// Bigger carts use the Sega mapper: 16KB banks, banks 0-1 fixed in slots
|
|
1324
|
+
// 0-1 ($0000/$4000), banks 2+ page into slot 2 ($8000) — one region per
|
|
1325
|
+
// bank so instructions never straddle a bank edge and every bank gets a
|
|
1326
|
+
// correct base address.
|
|
1327
|
+
if (data.length <= 0xC000) {
|
|
1328
|
+
regions.push({ name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(0)), startAddress: 0x0000, fileOffset: 0, label: "ROM ($0000)" });
|
|
1329
|
+
return regions;
|
|
1330
|
+
}
|
|
1331
|
+
const BANK = 0x4000;
|
|
1332
|
+
for (let off = 0; off < data.length; off += BANK) {
|
|
1333
|
+
const idx = off / BANK;
|
|
1334
|
+
const org = idx === 0 ? 0x0000 : idx === 1 ? 0x4000 : 0x8000;
|
|
1335
|
+
regions.push({
|
|
1336
|
+
name: `bank${idx}`, file: `bank${idx}.asm`,
|
|
1337
|
+
bytes: data.slice(off, off + BANK),
|
|
1338
|
+
startAddress: org, fileOffset: off,
|
|
1339
|
+
label: `Sega-mapper bank ${idx}${idx < 2 ? ` (fixed $${org.toString(16).toUpperCase()})` : " (slot 2, $8000)"}`,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1320
1342
|
return regions;
|
|
1321
1343
|
}
|
|
1322
1344
|
if (platform === "genesis") {
|
|
@@ -1334,12 +1356,58 @@ function planRegions(platform, data) {
|
|
|
1334
1356
|
return regions;
|
|
1335
1357
|
}
|
|
1336
1358
|
if (platform === "atari2600") {
|
|
1337
|
-
|
|
1359
|
+
// 4KB carts map flat at $F000. Banked carts (F8=8KB, F6=16KB, F4=32KB)
|
|
1360
|
+
// page 4KB banks into the SAME $F000 window — one region per bank.
|
|
1361
|
+
if (data.length <= 0x1000) {
|
|
1362
|
+
regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: 0xF000, fileOffset: 0, label: "4KB cart @ $F000" });
|
|
1363
|
+
return regions;
|
|
1364
|
+
}
|
|
1365
|
+
const BANK = 0x1000;
|
|
1366
|
+
for (let off = 0; off < data.length; off += BANK) {
|
|
1367
|
+
const idx = off / BANK;
|
|
1368
|
+
regions.push({
|
|
1369
|
+
name: `bank${idx}`, file: `bank${idx}.asm`,
|
|
1370
|
+
bytes: data.slice(off, off + BANK),
|
|
1371
|
+
startAddress: 0xF000, fileOffset: off,
|
|
1372
|
+
label: `banked cart 4KB bank ${idx} @ $F000`,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1338
1375
|
return regions;
|
|
1339
1376
|
}
|
|
1340
1377
|
if (platform === "atari7800") {
|
|
1341
|
-
|
|
1342
|
-
|
|
1378
|
+
// ≤48KB carts map flat at the top of the address space. SuperGame banked
|
|
1379
|
+
// carts (>48KB) page 16KB banks into $8000-$BFFF with the LAST bank fixed
|
|
1380
|
+
// at $C000. A 128-byte .a78 header (magic "ATARI7800" at offset 1) is
|
|
1381
|
+
// split into its own data region so the cart banks stay 16KB-aligned.
|
|
1382
|
+
const hasA78 = data.length >= 17 &&
|
|
1383
|
+
String.fromCharCode(...data.subarray(1, 10)) === "ATARI7800";
|
|
1384
|
+
const base = hasA78 ? 128 : 0;
|
|
1385
|
+
const cartLen = data.length - base;
|
|
1386
|
+
if (cartLen <= 0xC000) {
|
|
1387
|
+
const org = 0x10000 - data.length; // header kept in-region (proven flat path)
|
|
1388
|
+
regions.push({ name: "rom", file: "rom.asm", bytes: data.slice(0), startAddress: org & 0xFFFF, fileOffset: 0, label: `cart @ $${(org & 0xFFFF).toString(16)}` });
|
|
1389
|
+
return regions;
|
|
1390
|
+
}
|
|
1391
|
+
if (hasA78) {
|
|
1392
|
+
regions.push({
|
|
1393
|
+
name: "a78_header", file: "a78_header.asm", bytes: data.slice(0, 128), kind: "data",
|
|
1394
|
+
startAddress: 0x0000, fileOffset: 0,
|
|
1395
|
+
label: ".a78 header (128 B data)",
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
const BANK = 0x4000;
|
|
1399
|
+
const nBanks = Math.ceil(cartLen / BANK);
|
|
1400
|
+
for (let b = 0; b < nBanks; b++) {
|
|
1401
|
+
const isFixedTop = b === nBanks - 1;
|
|
1402
|
+
const org = isFixedTop ? 0xC000 : 0x8000;
|
|
1403
|
+
const fileOffset = base + b * BANK;
|
|
1404
|
+
regions.push({
|
|
1405
|
+
name: `bank${b}`, file: `bank${b}.asm`,
|
|
1406
|
+
bytes: data.slice(fileOffset, fileOffset + BANK),
|
|
1407
|
+
startAddress: org, fileOffset,
|
|
1408
|
+
label: `SuperGame bank ${b}${isFixedTop ? " (fixed $C000)" : " (switchable $8000)"}`,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1343
1411
|
return regions;
|
|
1344
1412
|
}
|
|
1345
1413
|
if (platform === "lynx") {
|
|
@@ -1363,30 +1431,83 @@ function planRegions(platform, data) {
|
|
|
1363
1431
|
return regions;
|
|
1364
1432
|
}
|
|
1365
1433
|
if (platform === "pce") {
|
|
1366
|
-
// PC Engine HuCard: the HuC6280 (65C02-family) image
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1434
|
+
// PC Engine HuCard: the HuC6280 (65C02-family) image, banked in 8KB pages
|
|
1435
|
+
// via the MPRs. A 512-byte copier header (len % 1024 == 512) is split into
|
|
1436
|
+
// its own data region. NO trailing-pad trim — HuCard $FF padding is REAL
|
|
1437
|
+
// cart bytes and trimming it made the old rebuild lossy (the planner's own
|
|
1438
|
+
// notes flagged this as the fix needed).
|
|
1439
|
+
// ≤32KB: flat at the top of the address space (vectors at $FFF6+ land
|
|
1440
|
+
// correctly — the proven small-HuCard assumption).
|
|
1441
|
+
// >32KB: one region per 8KB page; page 0 at $E000 (where MPR7 maps it
|
|
1442
|
+
// at reset — the vectors live there), pages 1+ at $8000 (neutral
|
|
1443
|
+
// window; bank registers decide at runtime).
|
|
1444
|
+
const hasCopier = (data.length % 1024) === 512;
|
|
1445
|
+
const base = hasCopier ? 512 : 0;
|
|
1446
|
+
if (hasCopier) {
|
|
1447
|
+
regions.push({
|
|
1448
|
+
name: "copier_header", file: "copier_header.asm", bytes: data.slice(0, 512), kind: "data",
|
|
1449
|
+
startAddress: 0x0000, fileOffset: 0,
|
|
1450
|
+
label: "512-byte copier header (data)",
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
const body = data.subarray(base);
|
|
1454
|
+
if (body.length <= 0x8000) {
|
|
1455
|
+
const org = (0x10000 - body.length) & 0xffff;
|
|
1456
|
+
regions.push({
|
|
1457
|
+
name: "rom", file: "rom.asm", bytes: body.slice(0),
|
|
1458
|
+
startAddress: org, fileOffset: base,
|
|
1459
|
+
label: `HuCard @ $${org.toString(16)} (HuC6280)`,
|
|
1460
|
+
});
|
|
1461
|
+
return regions;
|
|
1462
|
+
}
|
|
1463
|
+
const PAGE = 0x2000;
|
|
1464
|
+
const nPages = Math.ceil(body.length / PAGE);
|
|
1465
|
+
for (let b = 0; b < nPages; b++) {
|
|
1466
|
+
const org = b === 0 ? 0xE000 : 0x8000;
|
|
1467
|
+
regions.push({
|
|
1468
|
+
name: `page${b}`, file: `page${b}.asm`,
|
|
1469
|
+
bytes: body.slice(b * PAGE, (b + 1) * PAGE),
|
|
1470
|
+
startAddress: org, fileOffset: base + b * PAGE,
|
|
1471
|
+
label: `HuCard 8KB page ${b}${b === 0 ? " (reset MPR7 → $E000, vectors)" : " ($8000 ASSUMED — MPRs map pages at runtime)"}`,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1377
1474
|
return regions;
|
|
1378
1475
|
}
|
|
1379
1476
|
if (platform === "msx") {
|
|
1380
1477
|
// MSX cartridge maps at $4000-$BFFF; the 16-byte "AB" header at $4000 is
|
|
1381
|
-
// data (magic + INIT/STATEMENT/DEVICE/TEXT pointers), code follows.
|
|
1382
|
-
//
|
|
1478
|
+
// data (magic + INIT/STATEMENT/DEVICE/TEXT pointers), code follows.
|
|
1479
|
+
// ≤32KB: skip the header, one flat region from $4010.
|
|
1480
|
+
// >32KB (megaROM): 16KB banks via an ASCII16-style mapper — bank 0 at
|
|
1481
|
+
// $4000 (header split out as a data region), banks 1+ at $8000.
|
|
1383
1482
|
const hdr = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42;
|
|
1384
1483
|
const base = hdr ? 16 : 0;
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1484
|
+
if (data.length <= 0x8000 + base) {
|
|
1485
|
+
regions.push({
|
|
1486
|
+
name: "rom", file: "rom.asm", bytes: trimTrailingPad(data.slice(base)),
|
|
1487
|
+
startAddress: 0x4000 + base, fileOffset: base,
|
|
1488
|
+
label: `cart @ $${(0x4000 + base).toString(16)} (Z80)`,
|
|
1489
|
+
});
|
|
1490
|
+
return regions;
|
|
1491
|
+
}
|
|
1492
|
+
if (hdr) {
|
|
1493
|
+
regions.push({
|
|
1494
|
+
name: "ab_header", file: "ab_header.asm", bytes: data.slice(0, 16), kind: "data",
|
|
1495
|
+
startAddress: 0x4000, fileOffset: 0,
|
|
1496
|
+
label: `"AB" cartridge header (16 B data @ $4000)`,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
const BANK = 0x4000;
|
|
1500
|
+
const nBanks = Math.ceil(data.length / BANK);
|
|
1501
|
+
for (let b = 0; b < nBanks; b++) {
|
|
1502
|
+
const skip = b === 0 ? base : 0;
|
|
1503
|
+
const org = b === 0 ? 0x4000 + base : 0x8000;
|
|
1504
|
+
regions.push({
|
|
1505
|
+
name: `bank${b}`, file: `bank${b}.asm`,
|
|
1506
|
+
bytes: data.slice(b * BANK + skip, (b + 1) * BANK),
|
|
1507
|
+
startAddress: org, fileOffset: b * BANK + skip,
|
|
1508
|
+
label: `megaROM 16KB bank ${b}${b === 0 ? ` ($${org.toString(16)})` : " ($8000 ASSUMED — mapper pages at runtime)"}`,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1390
1511
|
return regions;
|
|
1391
1512
|
}
|
|
1392
1513
|
if (platform === "gba") {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// though they're not "instructions" per se.
|
|
8
8
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
|
-
import {
|
|
10
|
+
import { mapAtari2600Address, mapC64Address } from "./disasm.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Classify a referring instruction by its mnemonic.
|
|
@@ -75,8 +75,9 @@ function scanAsmForReferences(asm, targetAddr, sourceLabels) {
|
|
|
75
75
|
// Match: any `$XXXX` (or `$XX:XXXX`) in operand that resolves to target,
|
|
76
76
|
// OR the LXXXX auto-label for the target address, OR a named label.
|
|
77
77
|
let matched = false;
|
|
78
|
-
for (const m of operand.matchAll(
|
|
79
|
-
|
|
78
|
+
for (const m of operand.matchAll(/(#?)\$([0-9A-Fa-f]+)\b/g)) {
|
|
79
|
+
if (m[1] === "#") continue; /* immediate (`lda #$02`) — a value, not an address */
|
|
80
|
+
const v = parseInt(m[2], 16);
|
|
80
81
|
if (v === targetAddr || v === (targetAddr & 0xFFFF)) { matched = true; break; }
|
|
81
82
|
}
|
|
82
83
|
if (!matched && autoLabelRe.test(operand)) matched = true;
|
|
@@ -231,54 +232,163 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
|
|
|
231
232
|
throw new Error(`findReferences: could not detect platform for '${path}'. Pass platform explicitly.`);
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
// Disassemble the whole code area.
|
|
235
|
+
// Disassemble the whole code area. Flat platforms produce one asm blob;
|
|
236
|
+
// BANKED carts (NES mappers, SNES LoROM, GB MBC, Sega mapper, MSX megaROM,
|
|
237
|
+
// 2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards) produce one segment PER
|
|
238
|
+
// BANK (segments[]) — a flat-blob disasm mis-addresses everything past the
|
|
239
|
+
// first bank and lets instructions straddle bank edges, which corrupts the
|
|
240
|
+
// decode stream (the 0.27.0 refsFound:0 bug, fixed for NES first and now
|
|
241
|
+
// applied to every banked platform). Refs from a segment carry a bank tag.
|
|
235
242
|
let asm;
|
|
243
|
+
/** @type {{asm: string, bank: number}[] | null} */
|
|
244
|
+
let segments = null;
|
|
245
|
+
// Bound the per-bank da65/objdump fan-out on huge carts. 64 banks covers
|
|
246
|
+
// 1MB (16KB banks) / 512KB (8KB pages) — beyond that we scan the first 64
|
|
247
|
+
// and SAY SO in notes rather than silently truncating.
|
|
248
|
+
const SEGMENT_CAP = 64;
|
|
249
|
+
let segmentsCapped = 0;
|
|
236
250
|
if (resolved === "nes") {
|
|
237
251
|
const prgSize = data[4] * 16384;
|
|
238
|
-
const startAddress = prgSize === 16384 ? 0xC000 : 0x8000;
|
|
239
|
-
const bytes = data.slice(16, 16 + prgSize);
|
|
240
252
|
const { runDa65 } = await import("../../toolchains/cc65/da65.js");
|
|
241
|
-
|
|
242
|
-
|
|
253
|
+
if (prgSize <= 32768) {
|
|
254
|
+
const startAddress = prgSize === 16384 ? 0xC000 : 0x8000;
|
|
255
|
+
const bytes = data.slice(16, 16 + prgSize);
|
|
256
|
+
const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
|
|
257
|
+
asm = r.asm;
|
|
258
|
+
} else {
|
|
259
|
+
// Banked PRG (>32KB, e.g. UxROM/MMC1/MMC3): the old code disassembled
|
|
260
|
+
// the whole PRG as ONE flat blob at $8000, which mis-addresses every
|
|
261
|
+
// bank past the first — a 128KB mapper-2 scan returned refsFound:0
|
|
262
|
+
// for bytes referenced in dozens of places (0.27.0 feedback #3).
|
|
263
|
+
// Disassemble each 16KB bank separately: switchable banks at $8000,
|
|
264
|
+
// the (conventionally fixed) last bank at $C000; tag refs with the
|
|
265
|
+
// bank index.
|
|
266
|
+
const banks = Math.floor(prgSize / 16384);
|
|
267
|
+
segments = [];
|
|
268
|
+
for (let b = 0; b < banks; b++) {
|
|
269
|
+
const bytes = data.slice(16 + b * 16384, 16 + (b + 1) * 16384);
|
|
270
|
+
const startAddress = b === banks - 1 ? 0xC000 : 0x8000;
|
|
271
|
+
const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
|
|
272
|
+
segments.push({ asm: r.asm, bank: b });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
243
275
|
} else if (resolved === "snes") {
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
// LoROM: 32KB banks each mapped at $xx:8000. The old code disassembled
|
|
277
|
+
// ONLY the first 32KB bank — a 1MB cart's other 31 banks were invisible.
|
|
278
|
+
// Scan every 32KB bank at $8000 (absolute 16-bit operands are bank-window
|
|
279
|
+
// addresses on LoROM), tagged with the bank index.
|
|
280
|
+
const hasHeader = (data.length % 1024) === 512;
|
|
281
|
+
const body = hasHeader ? data.subarray(512) : data;
|
|
246
282
|
const { runDa65 } = await import("../../toolchains/cc65/da65.js");
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
283
|
+
const BANK = 0x8000;
|
|
284
|
+
const nBanks = Math.ceil(body.length / BANK);
|
|
285
|
+
const scanBanks = Math.min(nBanks, SEGMENT_CAP);
|
|
286
|
+
segmentsCapped = nBanks - scanBanks;
|
|
287
|
+
if (nBanks <= 1) {
|
|
288
|
+
const r = await runDa65({
|
|
289
|
+
bytes: body.slice(0, BANK), startAddress: 0x008000, cpu: "65816",
|
|
290
|
+
options: ["--comments", "4"],
|
|
291
|
+
info: `RANGE { START $8000; END $${(0x8000 + Math.min(body.length, BANK) - 1).toString(16).toUpperCase()}; TYPE Code; ADDRMODE "MX"; };\n`,
|
|
292
|
+
});
|
|
293
|
+
asm = r.asm;
|
|
294
|
+
} else {
|
|
295
|
+
segments = [];
|
|
296
|
+
for (let b = 0; b < scanBanks; b++) {
|
|
297
|
+
const bytes = body.slice(b * BANK, (b + 1) * BANK);
|
|
298
|
+
const r = await runDa65({
|
|
299
|
+
bytes, startAddress: 0x008000, cpu: "65816",
|
|
300
|
+
options: ["--comments", "4"],
|
|
301
|
+
info: `RANGE { START $8000; END $${(0x8000 + bytes.length - 1).toString(16).toUpperCase()}; TYPE Code; ADDRMODE "MX"; };\n`,
|
|
302
|
+
});
|
|
303
|
+
segments.push({ asm: r.asm, bank: b });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
253
306
|
} else if (resolved === "sms" || resolved === "gg") {
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
307
|
+
// Sega mapper: slots 0+1 ($0000-$7FFF) hold banks 0-1; slot 2 ($8000-
|
|
308
|
+
// $BFFF) pages in banks 2+. The old code scanned only the first 32KB —
|
|
309
|
+
// every bank past 1 was invisible. Scan bank 0 @ $0000, bank 1 @ $4000,
|
|
310
|
+
// banks 2+ @ $8000 (their pageable window), tagged with the bank index.
|
|
258
311
|
const { runObjdump } = await import("../../toolchains/objdump.js");
|
|
259
|
-
|
|
312
|
+
if (data.length <= 0x8000) {
|
|
313
|
+
asm = (await runObjdump({ bytes: data.slice(0), arch: "z80", startAddress: 0x0000 })).asm;
|
|
314
|
+
} else {
|
|
315
|
+
const BANK = 0x4000;
|
|
316
|
+
const nBanks = Math.ceil(data.length / BANK);
|
|
317
|
+
const scanBanks = Math.min(nBanks, SEGMENT_CAP);
|
|
318
|
+
segmentsCapped = nBanks - scanBanks;
|
|
319
|
+
segments = [];
|
|
320
|
+
for (let b = 0; b < scanBanks; b++) {
|
|
321
|
+
const bytes = data.slice(b * BANK, (b + 1) * BANK);
|
|
322
|
+
const startAddress = b === 0 ? 0x0000 : b === 1 ? 0x4000 : 0x8000;
|
|
323
|
+
segments.push({ asm: (await runObjdump({ bytes, arch: "z80", startAddress })).asm, bank: b });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
260
326
|
} else if (resolved === "gb" || resolved === "gbc") {
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
327
|
+
// MBC banking: bank 0 fixed at $0000, banks 1+ page into $4000-$7FFF.
|
|
328
|
+
// The old code scanned only the first 32KB (banks 0-1) — a 128KB MBC1
|
|
329
|
+
// cart's other 6 banks were invisible. Scan every 16KB bank (bank 0 @
|
|
330
|
+
// $0000, banks 1+ @ $4000), tagged with the bank index.
|
|
264
331
|
const { runObjdump } = await import("../../toolchains/objdump.js");
|
|
265
|
-
|
|
332
|
+
if (data.length <= 0x8000) {
|
|
333
|
+
asm = (await runObjdump({ bytes: data.slice(0), arch: "gbz80", startAddress: 0x0000 })).asm;
|
|
334
|
+
} else {
|
|
335
|
+
const BANK = 0x4000;
|
|
336
|
+
const nBanks = Math.ceil(data.length / BANK);
|
|
337
|
+
const scanBanks = Math.min(nBanks, SEGMENT_CAP);
|
|
338
|
+
segmentsCapped = nBanks - scanBanks;
|
|
339
|
+
segments = [];
|
|
340
|
+
for (let b = 0; b < scanBanks; b++) {
|
|
341
|
+
const bytes = data.slice(b * BANK, (b + 1) * BANK);
|
|
342
|
+
const startAddress = b === 0 ? 0x0000 : 0x4000;
|
|
343
|
+
segments.push({ asm: (await runObjdump({ bytes, arch: "gbz80", startAddress })).asm, bank: b });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
266
346
|
} else if (resolved === "atari2600") {
|
|
267
|
-
// 2600 cart maps to $F000-$FFFF
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
const mapped = mapAtari2600Address(data, 0xF000, 0x1000, 0);
|
|
347
|
+
// 2600 cart maps to $F000-$FFFF. Banked carts (F8=8KB, F6=16KB, F4=32KB,
|
|
348
|
+
// …) page 4KB banks into the SAME $F000 window. The old code scanned only
|
|
349
|
+
// the boot bank — fixed: scan every 4KB bank at $F000, tagged.
|
|
271
350
|
const { runDa65 } = await import("../../toolchains/cc65/da65.js");
|
|
272
|
-
|
|
273
|
-
|
|
351
|
+
if (data.length <= 0x1000) {
|
|
352
|
+
const mapped = mapAtari2600Address(data, 0xF000, 0x1000, 0);
|
|
353
|
+
const r = await runDa65({ bytes: mapped.bytes, startAddress: 0xF000, cpu: "6502", options: ["--comments", "4"] });
|
|
354
|
+
asm = r.asm;
|
|
355
|
+
} else {
|
|
356
|
+
const BANK = 0x1000;
|
|
357
|
+
const nBanks = Math.ceil(data.length / BANK);
|
|
358
|
+
segments = [];
|
|
359
|
+
for (let b = 0; b < nBanks; b++) {
|
|
360
|
+
const bytes = data.slice(b * BANK, (b + 1) * BANK);
|
|
361
|
+
const r = await runDa65({ bytes, startAddress: 0xF000, cpu: "6502", options: ["--comments", "4"] });
|
|
362
|
+
segments.push({ asm: r.asm, bank: b });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
274
365
|
} else if (resolved === "atari7800") {
|
|
275
|
-
// 7800
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
366
|
+
// 7800: flat carts (≤48KB) map at the top of the address space — scan the
|
|
367
|
+
// WHOLE cart (the old code scanned only $C000-$FFFF, hiding code at
|
|
368
|
+
// $4000-$BFFF on 32/48KB carts). SuperGame banked carts (>48KB) page
|
|
369
|
+
// 16KB banks into $8000-$BFFF with the last bank fixed at $C000 — scan
|
|
370
|
+
// per-bank, tagged. A 128-byte .a78 header is stripped if present.
|
|
371
|
+
const hasA78 = data.length >= 17 &&
|
|
372
|
+
String.fromCharCode(...data.subarray(1, 10)) === "ATARI7800";
|
|
373
|
+
const cart = hasA78 ? data.subarray(128) : data;
|
|
279
374
|
const { runDa65 } = await import("../../toolchains/cc65/da65.js");
|
|
280
|
-
|
|
281
|
-
|
|
375
|
+
if (cart.length <= 0xC000) {
|
|
376
|
+
const start = (0x10000 - cart.length) & 0xFFFF;
|
|
377
|
+
const r = await runDa65({ bytes: cart.slice(0), startAddress: start, cpu: "6502", options: ["--comments", "4"] });
|
|
378
|
+
asm = r.asm;
|
|
379
|
+
} else {
|
|
380
|
+
const BANK = 0x4000;
|
|
381
|
+
const nBanks = Math.ceil(cart.length / BANK);
|
|
382
|
+
const scanBanks = Math.min(nBanks, SEGMENT_CAP);
|
|
383
|
+
segmentsCapped = nBanks - scanBanks;
|
|
384
|
+
segments = [];
|
|
385
|
+
for (let b = 0; b < scanBanks; b++) {
|
|
386
|
+
const bytes = cart.slice(b * BANK, (b + 1) * BANK);
|
|
387
|
+
const startAddress = b === nBanks - 1 ? 0xC000 : 0x8000;
|
|
388
|
+
const r = await runDa65({ bytes, startAddress, cpu: "6502", options: ["--comments", "4"] });
|
|
389
|
+
segments.push({ asm: r.asm, bank: b });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
282
392
|
} else if (resolved === "c64") {
|
|
283
393
|
// c64 .prg: 2-byte load addr + code. Disasm from the load addr through
|
|
284
394
|
// EOF. For typical BASIC-stub programs that's $0801 + a few KB.
|
|
@@ -308,21 +418,58 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
|
|
|
308
418
|
const { runObjdump } = await import("../../toolchains/objdump.js");
|
|
309
419
|
asm = (await runObjdump({ bytes, arch: "m68k", startAddress: start })).asm;
|
|
310
420
|
} else if (resolved === "pce") {
|
|
311
|
-
// PC Engine HuCard: HuC6280 (65C02 superset)
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
421
|
+
// PC Engine HuCard: HuC6280 (65C02 superset), 8KB pages mapped via the
|
|
422
|
+
// MPRs. ≤32KB images map flat at the top of the address space (the old
|
|
423
|
+
// assumption — correct there). Bigger HuCards are banked: the old code
|
|
424
|
+
// computed a WRAPPED start address (garbage for >64KB) — fixed: scan
|
|
425
|
+
// every 8KB page, page 0 at $E000 (where MPR7 maps it at reset — the
|
|
426
|
+
// vectors live there), pages 1+ at $8000 (a neutral MPR4 window; the
|
|
427
|
+
// base only affects branch-target/auto-label matching, absolute operands
|
|
428
|
+
// match regardless). A 512-byte copier header is stripped if present.
|
|
429
|
+
const hasCopier = (data.length % 1024) === 512;
|
|
430
|
+
const body = hasCopier ? data.subarray(512) : data;
|
|
315
431
|
const { runDa65 } = await import("../../toolchains/cc65/da65.js");
|
|
316
|
-
|
|
317
|
-
|
|
432
|
+
if (body.length <= 0x8000) {
|
|
433
|
+
const start = (0x10000 - body.length) & 0xffff;
|
|
434
|
+
const r = await runDa65({ bytes: body.slice(0), startAddress: start, cpu: "huc6280", options: ["--comments", "4"] });
|
|
435
|
+
asm = r.asm;
|
|
436
|
+
} else {
|
|
437
|
+
const PAGE = 0x2000;
|
|
438
|
+
const nPages = Math.ceil(body.length / PAGE);
|
|
439
|
+
const scanPages = Math.min(nPages, SEGMENT_CAP);
|
|
440
|
+
segmentsCapped = nPages - scanPages;
|
|
441
|
+
segments = [];
|
|
442
|
+
for (let b = 0; b < scanPages; b++) {
|
|
443
|
+
const bytes = body.slice(b * PAGE, (b + 1) * PAGE);
|
|
444
|
+
const startAddress = b === 0 ? 0xE000 : 0x8000;
|
|
445
|
+
const r = await runDa65({ bytes, startAddress, cpu: "huc6280", options: ["--comments", "4"] });
|
|
446
|
+
segments.push({ asm: r.asm, bank: b });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
318
449
|
} else if (resolved === "msx") {
|
|
319
|
-
// MSX cartridge maps at $4000-$BFFF
|
|
320
|
-
//
|
|
450
|
+
// MSX cartridge maps at $4000-$BFFF. MegaROMs (>32KB) page 16KB banks via
|
|
451
|
+
// an ASCII16-style mapper — the old code scanned only the first 32KB.
|
|
452
|
+
// Scan bank 0 at $4000 (its fixed home, header skipped) and banks 1+ at
|
|
453
|
+
// $8000 (the conventional second window), tagged with the bank index.
|
|
321
454
|
const hdr = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42;
|
|
322
455
|
const base = hdr ? 16 : 0;
|
|
323
|
-
const bytes = data.slice(base, Math.min(data.length, base + 0x8000));
|
|
324
456
|
const { runObjdump } = await import("../../toolchains/objdump.js");
|
|
325
|
-
|
|
457
|
+
if (data.length <= 0x8000 + base) {
|
|
458
|
+
const bytes = data.slice(base, Math.min(data.length, base + 0x8000));
|
|
459
|
+
asm = (await runObjdump({ bytes, arch: "z80", startAddress: 0x4000 + base })).asm;
|
|
460
|
+
} else {
|
|
461
|
+
const BANK = 0x4000;
|
|
462
|
+
const nBanks = Math.ceil(data.length / BANK);
|
|
463
|
+
const scanBanks = Math.min(nBanks, SEGMENT_CAP);
|
|
464
|
+
segmentsCapped = nBanks - scanBanks;
|
|
465
|
+
segments = [];
|
|
466
|
+
for (let b = 0; b < scanBanks; b++) {
|
|
467
|
+
const skip = b === 0 ? base : 0;
|
|
468
|
+
const bytes = data.slice(b * BANK + skip, (b + 1) * BANK);
|
|
469
|
+
const startAddress = b === 0 ? 0x4000 + base : 0x8000;
|
|
470
|
+
segments.push({ asm: (await runObjdump({ bytes, arch: "z80", startAddress })).asm, bank: b });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
326
473
|
} else if (resolved === "gba") {
|
|
327
474
|
// GBA = ARM7TDMI, ROM maps flat at 0x08000000. Disassemble as ARM (the
|
|
328
475
|
// default; Thumb regions need disassembleRom with thumb:true). Native
|
|
@@ -339,7 +486,20 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
|
|
|
339
486
|
throw new Error(`findReferences: platform '${resolved}' not supported yet.`);
|
|
340
487
|
}
|
|
341
488
|
|
|
342
|
-
|
|
489
|
+
let refs;
|
|
490
|
+
if (segments) {
|
|
491
|
+
refs = [];
|
|
492
|
+
// NES refs keep the shipped `prgBank` tag; other banked platforms use the
|
|
493
|
+
// platform-neutral `romBank`.
|
|
494
|
+
const bankKey = resolved === "nes" ? "prgBank" : "romBank";
|
|
495
|
+
for (const seg of segments) {
|
|
496
|
+
for (const r of scanAsmForReferences(seg.asm, address, [])) {
|
|
497
|
+
refs.push({ ...r, [bankKey]: seg.bank });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
refs = scanAsmForReferences(asm, address, []);
|
|
502
|
+
}
|
|
343
503
|
// Add vector-table refs.
|
|
344
504
|
if (resolved === "nes") {
|
|
345
505
|
refs.push(...nesVectorRefs(data, address));
|
|
@@ -363,9 +523,14 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
|
|
|
363
523
|
truncated: refs.length > maxRefsReturned
|
|
364
524
|
? `${refs.length - maxRefsReturned} additional references not returned (raise maxRefsReturned).`
|
|
365
525
|
: undefined,
|
|
366
|
-
notes:
|
|
367
|
-
|
|
368
|
-
|
|
526
|
+
notes: [
|
|
527
|
+
refs.length === 0
|
|
528
|
+
? `No references found. Address $${address.toString(16).toUpperCase()} may be unreached, or an indirect/computed jump target.`
|
|
529
|
+
: null,
|
|
530
|
+
segmentsCapped > 0
|
|
531
|
+
? `Scan covered the first ${SEGMENT_CAP} banks only — ${segmentsCapped} additional bank(s) were NOT scanned (very large cart).`
|
|
532
|
+
: null,
|
|
533
|
+
].filter(Boolean).join(" ") || undefined,
|
|
369
534
|
};
|
|
370
535
|
}
|
|
371
536
|
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -8,6 +8,7 @@ import { imageContent, jsonContent, safeTool } from "../util.js";
|
|
|
8
8
|
import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
|
|
9
9
|
import { stepInstructionCore, attachObserverFrame } from "./watch-memory.js";
|
|
10
10
|
import { getRenderingContextCore } from "./rendering-context.js";
|
|
11
|
+
import { humanCoDriveWarning } from "./playtest.js";
|
|
11
12
|
|
|
12
13
|
// Normalize each platform's render-context into a CONSERVATIVE renderEnabled
|
|
13
14
|
// (true | false | null). null = "can't tell from the registers" — verify never
|
|
@@ -244,10 +245,15 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
244
245
|
async function doStep({ frames }) {
|
|
245
246
|
const host = getHost(sessionKey);
|
|
246
247
|
const n = host.stepFrames(frames);
|
|
248
|
+
// Surface a co-drive conflict the moment the agent steps: a human
|
|
249
|
+
// actively playing in the playtest window means this step raced their
|
|
250
|
+
// real-time loop. Field only appears when the conflict is real.
|
|
251
|
+
const coDrive = humanCoDriveWarning(sessionKey);
|
|
247
252
|
return jsonContent({
|
|
248
253
|
framesRun: n,
|
|
249
254
|
frameCount: host.status.frameCount,
|
|
250
255
|
framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight },
|
|
256
|
+
...(coDrive ? { humanCoDriveWarning: coDrive } : {}),
|
|
251
257
|
});
|
|
252
258
|
}
|
|
253
259
|
|
|
@@ -371,16 +377,17 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
371
377
|
const host = getHost(sessionKey);
|
|
372
378
|
host.stepFrames(frames);
|
|
373
379
|
const shot = host.screenshot();
|
|
380
|
+
const coDrive = humanCoDriveWarning(sessionKey);
|
|
374
381
|
if (!inline) {
|
|
375
382
|
await writeFile(outPath, Buffer.from(shot.pngBase64, "base64"));
|
|
376
|
-
const json = jsonContent({ path: outPath, frameCount: host.status.frameCount, width: shot.width, height: shot.height });
|
|
383
|
+
const json = jsonContent({ path: outPath, frameCount: host.status.frameCount, width: shot.width, height: shot.height, ...(coDrive ? { humanCoDriveWarning: coDrive } : {}) });
|
|
377
384
|
json._observerImages = [{ kind: "image", mimeType: "image/png", base64: shot.pngBase64 }];
|
|
378
385
|
return json;
|
|
379
386
|
}
|
|
380
387
|
return {
|
|
381
388
|
content: [
|
|
382
389
|
imageContent(shot.pngBase64),
|
|
383
|
-
{ type: "text", text: `stepped ${frames} → frame ${host.status.frameCount} (${shot.width}x${shot.height})` },
|
|
390
|
+
{ type: "text", text: `stepped ${frames} → frame ${host.status.frameCount} (${shot.width}x${shot.height})${coDrive ? `\nWARNING: ${coDrive}` : ""}` },
|
|
384
391
|
],
|
|
385
392
|
};
|
|
386
393
|
}
|
|
@@ -393,7 +400,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
393
400
|
"level with 7200; prefer ONE big call.\n" +
|
|
394
401
|
"'screenshot': capture the latest frame. `format:'png'` (default, exact colors) or `'ascii'` (lossy chafa text " +
|
|
395
402
|
"render for agents that can't view images). `overlayBoxes` (png) draws a box per visible sprite (SNES+NES only); " +
|
|
396
|
-
|
|
403
|
+
"`scale` (png) resamples nearest-neighbor: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5 — the useful direction, for cheap 'did it change?' checks). integer scale≥2 UPscales (pixel-duplication, e.g. scale:4 → GB 160x144 → 640x576) — but this adds NO detail (it's the same pixels enlarged) and costs MORE image tokens; the native frame already has every pixel. Prefer scale:1 (default, native). Only upscale if YOUR client renders tiny images too small to be useful AND can't zoom — and know that VLM encoders resize to a fixed resolution anyway, so it may not change what the model sees (and can slightly degrade it). ascii cols/rows/symbols/colors knobs in the param hints. " +
|
|
397
404
|
"**CHEAP VERIFY: for a binary pass/fail check (theme changed? sprite present? HUD ticked?) prefer scale:0.5 or " +
|
|
398
405
|
"format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
|
|
399
406
|
"assertion that costs zero image tokens.**\n" +
|
|
@@ -417,7 +424,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
417
424
|
path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
|
|
418
425
|
inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
|
|
419
426
|
overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
|
|
420
|
-
scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens for cheap 'did it change?' checks)
|
|
427
|
+
scale: z.number().gt(0).max(16).refine((s) => s <= 1 || Number.isInteger(s), { message: "scale must be 0<scale≤1 (downscale) or an integer ≥2 (upscale)" }).optional().describe("op=screenshot png: nearest-neighbor resample factor. DEFAULT (unset/1) = NATIVE resolution — perfect pixels, the accurate representation; use this. 0<scale<1 DOWNscales (0.5 ≈ 75% fewer image tokens — useful for cheap 'did it change?' checks). integer scale≥2 UPscales by pixel-duplication (e.g. scale:4 → GB 160x144 → 640x576): it adds NO information (same pixels enlarged), costs MORE image tokens, and since VLM encoders resize to their own fixed resolution it may not change what the model sees and can slightly degrade it. Only for clients that render tiny images too small to use and can't zoom."),
|
|
421
428
|
cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/16)."),
|
|
422
429
|
rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/16)."),
|
|
423
430
|
symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),
|