romdevtools 0.41.0 → 0.42.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile-65816.js +584 -0
  9. package/src/cheats/gamegenie.js +14 -2
  10. package/src/cores/registry.js +43 -51
  11. package/src/mcp/state.js +91 -2
  12. package/src/mcp/tools/cheats.js +10 -1
  13. package/src/mcp/tools/disasm.js +309 -21
  14. package/src/mcp/tools/find-references.js +93 -2
  15. package/src/mcp/tools/frame.js +440 -4
  16. package/src/mcp/tools/index.js +34 -1
  17. package/src/mcp/tools/lifecycle.js +53 -24
  18. package/src/mcp/tools/memory.js +1 -1
  19. package/src/mcp/tools/playtest.js +24 -1
  20. package/src/mcp/tools/project.js +1 -1
  21. package/src/mcp/tools/rendering-context.js +4 -2
  22. package/src/mcp/tools/watch-memory.js +87 -9
  23. package/src/mcp/util.js +45 -0
  24. package/src/playtest/playtest.js +115 -46
  25. package/src/toolchains/cc65/da65.js +7 -0
  26. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  27. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  28. package/src/cores/wasm/fceumm_libretro.js +0 -2
  29. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  30. package/src/cores/wasm/gambatte_libretro.js +0 -2
  31. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  32. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  33. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  34. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  35. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  36. package/src/cores/wasm/handy_libretro.js +0 -2
  37. package/src/cores/wasm/handy_libretro.wasm +0 -0
  38. package/src/cores/wasm/mgba_libretro.js +0 -2
  39. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  40. package/src/cores/wasm/prosystem_libretro.js +0 -2
  41. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  42. package/src/cores/wasm/snes9x_libretro.js +0 -2
  43. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  44. package/src/cores/wasm/stella2014_libretro.js +0 -2
  45. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  46. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  47. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { PNG } from "pngjs";
5
5
  import { resamplePng } from "../../host/framebuffer.js";
6
- import { getHost } from "../state.js";
6
+ import { getHost, getHostB } from "../state.js";
7
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";
@@ -241,6 +241,79 @@ function hsvToRgb(h, s, v) {
241
241
  return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
242
242
  }
243
243
 
244
+ /**
245
+ * Lightweight pixel summary of an RGBA framebuffer — the same dominant-color /
246
+ * distinct-color scan computeVerify uses, factored out so sideBySide can report
247
+ * a per-pane "is this side alive / how different is it" signal without the full
248
+ * render-context decode. No host needed; pure pixels. Exported for tests.
249
+ */
250
+ export function pixelSummary(width, height, rgba) {
251
+ const counts = new Map();
252
+ const total = width * height;
253
+ for (let i = 0; i + 3 < rgba.length; i += 4) {
254
+ const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
255
+ counts.set(key, (counts.get(key) || 0) + 1);
256
+ }
257
+ let topColor = 0, topCount = 0;
258
+ for (const [c, n] of counts) if (n > topCount) { topCount = n; topColor = c; }
259
+ const dominantFraction = total ? topCount / total : 1;
260
+ return {
261
+ width, height,
262
+ distinctColors: counts.size,
263
+ dominantColor: "#" + topColor.toString(16).padStart(6, "0"),
264
+ dominantPct: Math.round(dominantFraction * 1000) / 10,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Composite two framebuffers into one PNG, A on the left and B on the right,
270
+ * separated by a vertical divider. Panes are integer-upscaled to a shared
271
+ * height (the taller of the two) so a small handheld next to a console reads at
272
+ * a comparable size; the upscale is nearest-neighbor to keep pixels crisp. The
273
+ * background fills any letterbox gaps. Returns the encoded PNG buffer.
274
+ *
275
+ * @param {{width:number,height:number,rgba:Uint8Array|Buffer}} a left pane
276
+ * @param {{width:number,height:number,rgba:Uint8Array|Buffer}} b right pane
277
+ * @param {number} gap divider width in px
278
+ * Exported for tests.
279
+ */
280
+ export function compositeSideBySide(a, b, gap = 4) {
281
+ // Integer scale each pane up toward the common (max) height. Integer-only so
282
+ // pixel art stays sharp; a pane that doesn't divide evenly is centered.
283
+ const targetH = Math.max(a.height, b.height);
284
+ const scaleFor = (h) => Math.max(1, Math.floor(targetH / h));
285
+ const aScale = scaleFor(a.height);
286
+ const bScale = scaleFor(b.height);
287
+ const aW = a.width * aScale, aH = a.height * aScale;
288
+ const bW = b.width * bScale, bH = b.height * bScale;
289
+ const outH = Math.max(aH, bH);
290
+ const outW = aW + gap + bW;
291
+ const out = new PNG({ width: outW, height: outH });
292
+ // Backdrop: a neutral dark gray so a black game frame is still distinguishable
293
+ // from the canvas, and the divider reads.
294
+ for (let i = 0; i < out.data.length; i += 4) {
295
+ out.data[i] = 0x20; out.data[i + 1] = 0x20; out.data[i + 2] = 0x20; out.data[i + 3] = 0xFF;
296
+ }
297
+ const blit = (pane, scale, dstX, dstW, dstH) => {
298
+ const offY = Math.floor((outH - dstH) / 2); // vertically center a shorter pane
299
+ for (let y = 0; y < dstH; y++) {
300
+ const srcY = Math.floor(y / scale);
301
+ for (let x = 0; x < dstW; x++) {
302
+ const srcX = Math.floor(x / scale);
303
+ const s = (srcY * pane.width + srcX) * 4;
304
+ const d = ((offY + y) * outW + (dstX + x)) * 4;
305
+ out.data[d] = pane.rgba[s];
306
+ out.data[d + 1] = pane.rgba[s + 1];
307
+ out.data[d + 2] = pane.rgba[s + 2];
308
+ out.data[d + 3] = 0xFF;
309
+ }
310
+ }
311
+ };
312
+ blit(a, aScale, 0, aW, aH);
313
+ blit(b, bScale, aW + gap, bW, bH);
314
+ return { buffer: PNG.sync.write(out), outW, outH, aScale, bScale };
315
+ }
316
+
244
317
  export function registerFrameTools(server, z, sessionKey) {
245
318
  async function doStep({ frames }) {
246
319
  const host = getHost(sessionKey);
@@ -373,6 +446,334 @@ export function registerFrameTools(server, z, sessionKey) {
373
446
  return json;
374
447
  }
375
448
 
449
+ // op:'sideBySide' — capture BOTH hosts (slot A + slot B) into one composited
450
+ // PNG, A left, B right. The two-cores-in-one-call capture for the port-compare
451
+ // loop: load the original in slot A, the port in slot B, step both the same N
452
+ // frames, and look at them together. Also returns a per-pane pixel summary so
453
+ // a no-vision agent gets a structured "both alive? how different?" signal.
454
+ async function doSideBySide({ frames, path: outPath, inline }) {
455
+ requireImageTarget(outPath, inline, "frame({op:'sideBySide'})");
456
+ const hostA = getHost(sessionKey); // throws with slot-A recovery guidance
457
+ const hostB = getHostB(sessionKey); // throws with "load slot B" guidance
458
+ if (frames && frames > 0) {
459
+ // Step both the same amount so the comparison is at the same game time.
460
+ hostA.stepFrames(frames);
461
+ hostB.stepFrames(frames);
462
+ }
463
+ const a = hostA.screenshotRgba();
464
+ const b = hostB.screenshotRgba();
465
+ const { buffer, outW, outH, aScale, bScale } = compositeSideBySide(a, b);
466
+ const pngBase64 = buffer.toString("base64");
467
+ const panes = {
468
+ a: { platform: hostA.status.platform, frame: hostA.status.frameCount, scale: aScale, ...pixelSummary(a.width, a.height, a.rgba) },
469
+ b: { platform: hostB.status.platform, frame: hostB.status.frameCount, scale: bScale, ...pixelSummary(b.width, b.height, b.rgba) },
470
+ };
471
+ if (!inline) {
472
+ await writeFile(outPath, buffer);
473
+ const json = jsonContent({ path: outPath, width: outW, height: outH, layout: "A|B", panes });
474
+ // Show the human the composite (not either raw pane) on the livestream.
475
+ json._observerImages = [{ kind: "image", mimeType: "image/png", base64: pngBase64 }];
476
+ return json;
477
+ }
478
+ const tempPath = path.join(tmpdir(), `romdev-sidebyside-${hostA.status.platform ?? "a"}-vs-${hostB.status.platform ?? "b"}.png`);
479
+ try { await writeFile(tempPath, buffer); } catch { /* best-effort */ }
480
+ return {
481
+ content: [
482
+ imageContent(pngBase64),
483
+ { type: "text", text: `side-by-side ${outW}x${outH} — left: ${panes.a.platform} @frame ${panes.a.frame} (${panes.a.distinctColors} colors), right: ${panes.b.platform} @frame ${panes.b.frame} (${panes.b.distinctColors} colors). Also written to ${tempPath}.` },
484
+ ],
485
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: pngBase64 }],
486
+ };
487
+ }
488
+
489
+ // op:'compareRam' — the RAM-diff oracle. The STATE-level sibling of
490
+ // sideBySide: instead of comparing pixels, compare the work-RAM of slot A
491
+ // (the original) and slot B (the port) at the same game-moment. This is how a
492
+ // logic port is proven correct INDEPENDENT of graphics — if the two machines'
493
+ // RAM matches, the game logic is running identically even when one renders
494
+ // blank (no PPU shim yet).
495
+ //
496
+ // Designed for a "smart-enough, not frontier" agent: it does the byte-compare
497
+ // MECHANICALLY and returns a DIGESTED verdict — a match %, the diverging
498
+ // address RANGES (run-length-encoded, not raw bytes), and plain-language
499
+ // guidance — so the agent gets "addresses $0300-$0312 differ, likely your
500
+ // sprite table" instead of two 2KB hex blobs to eyeball.
501
+ // Core RAM comparison between the two slots for one region. Returns the raw
502
+ // numbers + RLE ranges; the op wrappers add verdict text. Shared by
503
+ // compareRam and portStatus. Throws if either slot lacks the region.
504
+ function computeRamMatch(region) {
505
+ const hostA = getHost(sessionKey);
506
+ const hostB = getHostB(sessionKey);
507
+ const sizeA = hostA.regionSize ? hostA.regionSize(region) : 0;
508
+ const sizeB = hostB.regionSize ? hostB.regionSize(region) : 0;
509
+ if (!sizeA || !sizeB) {
510
+ throw new Error(`region '${region}' not available on ${!sizeA ? "slot A (" + hostA.status.platform + ")" : "slot B (" + hostB.status.platform + ")"}. Both hosts must expose it; 'system_ram' is the portable default.`);
511
+ }
512
+ const len = Math.min(sizeA, sizeB);
513
+ const a = hostA.readMemory(region, 0, len);
514
+ const b = hostB.readMemory(region, 0, len);
515
+ const ranges = [];
516
+ let diffBytes = 0;
517
+ let runStart = -1;
518
+ for (let i = 0; i < len; i++) {
519
+ const differ = a[i] !== b[i];
520
+ if (differ) {
521
+ diffBytes++;
522
+ if (runStart < 0) runStart = i;
523
+ } else if (runStart >= 0) {
524
+ ranges.push({ start: runStart, end: i - 1, length: i - runStart });
525
+ runStart = -1;
526
+ }
527
+ }
528
+ if (runStart >= 0) ranges.push({ start: runStart, end: len - 1, length: len - runStart });
529
+ const matchPct = len ? Math.round(((len - diffBytes) / len) * 1000) / 10 : 100;
530
+ return { sizeA, sizeB, len, a, b, ranges, diffBytes, matchPct };
531
+ }
532
+
533
+ function compareRam({ region = "system_ram", frames, maxRanges = 24 }) {
534
+ const hostA = getHost(sessionKey);
535
+ const hostB = getHostB(sessionKey);
536
+ if (frames && frames > 0) { hostA.stepFrames(frames); hostB.stepFrames(frames); }
537
+
538
+ let m;
539
+ try { m = computeRamMatch(region); }
540
+ catch (e) { throw new Error(`frame({op:'compareRam'}): ${e.message}`); }
541
+ const { sizeA, sizeB, len, a, b, ranges, diffBytes, matchPct } = m;
542
+ // Keep the biggest diverging spans (most informative), cap the list.
543
+ const sorted = [...ranges].sort((x, y) => y.length - x.length);
544
+ const shown = sorted.slice(0, maxRanges).map((r) => ({
545
+ range: `$${r.start.toString(16).toUpperCase().padStart(4, "0")}-$${r.end.toString(16).toUpperCase().padStart(4, "0")}`,
546
+ bytes: r.length,
547
+ // a tiny sample so the agent can sanity-check WITHOUT a separate read
548
+ a: Buffer.from(a.subarray(r.start, Math.min(r.start + 4, r.end + 1))).toString("hex"),
549
+ b: Buffer.from(b.subarray(r.start, Math.min(r.start + 4, r.end + 1))).toString("hex"),
550
+ }));
551
+
552
+ const verdict =
553
+ diffBytes === 0
554
+ ? "IDENTICAL — slot A and slot B work-RAM match byte-for-byte. The port's logic is running exactly like the original at this moment."
555
+ : matchPct >= 95
556
+ ? `CLOSE (${matchPct}% match) — logic is largely tracking; ${ranges.length} diverging span(s). Inspect the ranges below (often graphics/timing scratch that doesn't affect logic). Read a range with memory({op:'read', region, offset}) on each slot to dig in.`
557
+ : `DIVERGED (${matchPct}% match) — the port's logic is NOT tracking the original. Step both from a known-identical point (state restore), then compareRam after a few frames to find WHERE they split. The first diverging span is usually the root cause.`;
558
+
559
+ return jsonContent({
560
+ op: "compareRam",
561
+ region,
562
+ a: { platform: hostA.status.platform, frame: hostA.status.frameCount, bytes: sizeA },
563
+ b: { platform: hostB.status.platform, frame: hostB.status.frameCount, bytes: sizeB },
564
+ comparedBytes: len,
565
+ matchPct,
566
+ identical: diffBytes === 0,
567
+ divergingBytes: diffBytes,
568
+ divergingSpans: ranges.length,
569
+ ranges: shown,
570
+ ...(ranges.length > shown.length ? { rangesOmitted: ranges.length - shown.length } : {}),
571
+ note: verdict,
572
+ });
573
+ }
574
+
575
+ // op:'findDiverge' — the ROOT-CAUSE finder. compareRam tells you slot A and
576
+ // slot B differ; this tells you EXACTLY WHEN and WHERE they first split. It
577
+ // snapshots both hosts, steps them in lockstep, and reports the first frame at
578
+ // which the work-RAM diverges + the first diverging byte address. A
579
+ // smart-enough agent shouldn't binary-search frames by hand — the tool does
580
+ // the search and hands back "frame 47, $0312 (A=05 B=07): that's where your
581
+ // port's logic split from the original."
582
+ //
583
+ // Both hosts' MACHINE STATE (RAM/CPU/PPU) is restored to the pre-search point
584
+ // afterward via unserializeState, so the agent keeps working from where it was.
585
+ // (The frameCount COUNTER keeps climbing — a known core behavior of
586
+ // unserializeState — but the actual emulated state is rewound.)
587
+ function findDiverge({ region = "system_ram", maxFrames = 600 }) {
588
+ const hostA = getHost(sessionKey);
589
+ const hostB = getHostB(sessionKey);
590
+ const sizeA = hostA.regionSize ? hostA.regionSize(region) : 0;
591
+ const sizeB = hostB.regionSize ? hostB.regionSize(region) : 0;
592
+ if (!sizeA || !sizeB) {
593
+ throw new Error(`frame({op:'findDiverge'}): region '${region}' not available on ${!sizeA ? "slot A (" + hostA.status.platform + ")" : "slot B (" + hostB.status.platform + ")"}. Both hosts must expose it; 'system_ram' is the portable default.`);
594
+ }
595
+ const len = Math.min(sizeA, sizeB);
596
+ // Save both so the search is non-destructive.
597
+ const saveA = hostA.serializeState();
598
+ const saveB = hostB.serializeState();
599
+ const startFrameA = hostA.status.frameCount;
600
+
601
+ const firstDiff = () => {
602
+ const a = hostA.readMemory(region, 0, len);
603
+ const b = hostB.readMemory(region, 0, len);
604
+ for (let i = 0; i < len; i++) if (a[i] !== b[i]) return { offset: i, a: a[i], b: b[i] };
605
+ return null;
606
+ };
607
+
608
+ let result;
609
+ // If they already differ at frame 0, that IS the divergence point.
610
+ let cur = firstDiff();
611
+ if (cur) {
612
+ result = { diverged: true, atFrame: 0, framesStepped: 0, offset: cur.offset, a: cur.a, b: cur.b };
613
+ } else {
614
+ // Step in lockstep, one frame at a time, until the first split or maxFrames.
615
+ let stepped = 0;
616
+ let found = null;
617
+ for (let f = 1; f <= maxFrames; f++) {
618
+ hostA.stepFrames(1);
619
+ hostB.stepFrames(1);
620
+ stepped = f;
621
+ cur = firstDiff();
622
+ if (cur) { found = { atFrame: f, offset: cur.offset, a: cur.a, b: cur.b }; break; }
623
+ }
624
+ result = found
625
+ ? { diverged: true, atFrame: found.atFrame, framesStepped: stepped, offset: found.offset, a: found.a, b: found.b }
626
+ : { diverged: false, framesStepped: stepped };
627
+ }
628
+
629
+ // Restore both hosts to where they were before the search.
630
+ try { hostA.unserializeState(saveA); } catch { /* best-effort */ }
631
+ try { hostB.unserializeState(saveB); } catch { /* best-effort */ }
632
+
633
+ const addrHex = result.offset != null ? "$" + result.offset.toString(16).toUpperCase().padStart(4, "0") : null;
634
+ return jsonContent({
635
+ op: "findDiverge",
636
+ region,
637
+ a: { platform: hostA.status.platform },
638
+ b: { platform: hostB.status.platform },
639
+ searchStartedAtFrame: startFrameA,
640
+ ...result,
641
+ ...(addrHex ? { address: addrHex } : {}),
642
+ note: result.diverged
643
+ ? `First divergence at frame ${result.atFrame} (relative to search start), address ${addrHex}: slot A = $${result.a.toString(16).padStart(2, "0")}, slot B = $${result.b.toString(16).padStart(2, "0")}. This is where the port's logic first split from the original — decompile/disasm around the code that writes ${addrHex} on BOTH sides to find why. Both hosts' machine state (RAM/CPU/PPU) restored to the pre-search point.`
644
+ : `No divergence in ${result.framesStepped} frames — the port's logic tracks the original across this window. Step further or raise maxFrames if you expect a later split. Both hosts' machine state restored to the pre-search point.`,
645
+ });
646
+ }
647
+
648
+ // op:'compareRender' — the PRESENTATION oracle. The graphics-side sibling of
649
+ // compareRam: instead of bytes, compare the decoded RENDERING STATE of slot A
650
+ // (original) vs slot B (port) — "BG enabled? which tilemap/palette? sprites
651
+ // on? forced blank?" This is what an agent building/tuning the graphics shim
652
+ // needs: it says exactly WHAT the port's presentation is missing vs. the
653
+ // original, in plain terms, without the agent decoding registers by hand.
654
+ //
655
+ // Works cross-platform: each side is decoded by its own platform's
656
+ // rendering-context decoder (NES PPU, SNES PPU, Genesis VDP, ...), then the
657
+ // human-readable `summary` lines are diffed. Same-platform ports get a literal
658
+ // line diff; cross-platform ports get both summaries side by side (the agent
659
+ // maps concepts, since e.g. "BG1 tile base" has no NES equivalent).
660
+ async function compareRender({ frames }) {
661
+ const hostA = getHost(sessionKey);
662
+ const hostB = getHostB(sessionKey);
663
+ if (frames && frames > 0) { hostA.stepFrames(frames); hostB.stepFrames(frames); }
664
+ const platA = hostA.status.platform;
665
+ const platB = hostB.status.platform;
666
+ let ctxA, ctxB;
667
+ try { ctxA = await getRenderingContextCore({ platform: platA, area: "all", host: hostA }); }
668
+ catch (e) { ctxA = { platform: platA, summary: [`(render-context decode unavailable: ${e.message})`] }; }
669
+ try { ctxB = await getRenderingContextCore({ platform: platB, area: "all", host: hostB }); }
670
+ catch (e) { ctxB = { platform: platB, summary: [`(render-context decode unavailable: ${e.message})`] }; }
671
+
672
+ const sumA = Array.isArray(ctxA.summary) ? ctxA.summary : [];
673
+ const sumB = Array.isArray(ctxB.summary) ? ctxB.summary : [];
674
+ const samePlatform = platA === platB;
675
+
676
+ let lineDiff = null;
677
+ if (samePlatform) {
678
+ // Literal line diff: what the original shows that the port doesn't (and vice versa).
679
+ const setB = new Set(sumB);
680
+ const setA = new Set(sumA);
681
+ lineDiff = {
682
+ onlyInOriginal: sumA.filter((l) => !setB.has(l)),
683
+ onlyInPort: sumB.filter((l) => !setA.has(l)),
684
+ matching: sumA.filter((l) => setB.has(l)).length,
685
+ };
686
+ }
687
+
688
+ // Per-slot render-enable verdict (reuse the same logic verify uses).
689
+ const flagsA = pickRenderFlags(ctxA);
690
+ const flagsB = pickRenderFlags(ctxB);
691
+
692
+ const note = samePlatform
693
+ ? (lineDiff.onlyInOriginal.length === 0 && lineDiff.onlyInPort.length === 0
694
+ ? "Rendering state MATCHES — same platform, identical decoded render context. The port's presentation tracks the original."
695
+ : `Rendering differs: ${lineDiff.onlyInOriginal.length} aspect(s) the ORIGINAL has that the port lacks (see onlyInOriginal — that's your shim's TODO list), ${lineDiff.onlyInPort.length} the port has extra. Fix the port until onlyInOriginal is empty.`)
696
+ : `Cross-platform port (${platA}→${platB}): the two render models differ by hardware, so compare the summaries conceptually. originalRenderEnabled=${flagsA.renderEnabled}, portRenderEnabled=${flagsB.renderEnabled}. If the original renders and the port is forced-blank/disabled, the graphics shim hasn't enabled output yet — that's step one.`;
697
+
698
+ return jsonContent({
699
+ op: "compareRender",
700
+ a: { platform: platA, renderEnabled: flagsA.renderEnabled, summary: sumA },
701
+ b: { platform: platB, renderEnabled: flagsB.renderEnabled, summary: sumB },
702
+ samePlatform,
703
+ ...(lineDiff ? { diff: lineDiff } : {}),
704
+ note,
705
+ });
706
+ }
707
+
708
+ // op:'portStatus' — the CAPSTONE. One call that runs all the compare signals
709
+ // (logic via RAM, presentation via render state, pixels via the content scan)
710
+ // and returns a SINGLE digested "state of your port" verdict with the next
711
+ // concrete action. For a smart-enough agent this collapses "which of the 4
712
+ // oracles do I run, in what order, and how do I read them together?" into one
713
+ // answer: "logic matches 100%, but the port renders blank → build the graphics
714
+ // shim; start by enabling display output."
715
+ async function portStatus({ frames, region = "system_ram" }) {
716
+ const hostA = getHost(sessionKey);
717
+ const hostB = getHostB(sessionKey);
718
+ if (frames && frames > 0) { hostA.stepFrames(frames); hostB.stepFrames(frames); }
719
+
720
+ // 1. Logic (RAM) — only meaningful for a SAME-platform port (cross-platform
721
+ // RAM layouts differ, so a byte diff there isn't a logic verdict).
722
+ const samePlatform = hostA.status.platform === hostB.status.platform;
723
+ let logic = null;
724
+ if (samePlatform) {
725
+ try {
726
+ const m = computeRamMatch(region);
727
+ logic = { region, matchPct: m.matchPct, identical: m.diffBytes === 0, divergingBytes: m.diffBytes, divergingSpans: m.ranges.length };
728
+ } catch (e) { logic = { error: e.message }; }
729
+ }
730
+
731
+ // 2. Presentation (render state) — per-side render-enable.
732
+ let renderA, renderB;
733
+ try { renderA = pickRenderFlags(await getRenderingContextCore({ platform: hostA.status.platform, area: "all", host: hostA })); }
734
+ catch { renderA = { renderEnabled: null }; }
735
+ try { renderB = pickRenderFlags(await getRenderingContextCore({ platform: hostB.status.platform, area: "all", host: hostB })); }
736
+ catch { renderB = { renderEnabled: null }; }
737
+
738
+ // 3. Pixels — is each side drawing more than a flat color?
739
+ const pxA = pixelSummary(...rgbaTriple(hostA));
740
+ const pxB = pixelSummary(...rgbaTriple(hostB));
741
+ const aliveA = pxA.distinctColors > 2 && pxA.dominantPct < 99;
742
+ const aliveB = pxB.distinctColors > 2 && pxB.dominantPct < 99;
743
+
744
+ // Fuse into a single next-action verdict.
745
+ let verdict, nextAction;
746
+ if (samePlatform && logic && logic.identical) {
747
+ if (aliveB) { verdict = "PORT LOOKS COMPLETE"; nextAction = "Logic matches byte-for-byte AND the port renders. Spot-check with frame({op:'sideBySide'}) and move on."; }
748
+ else { verdict = "LOGIC DONE, PRESENTATION MISSING"; nextAction = "RAM matches the original exactly — the game logic is correct. The port renders blank: build/finish the graphics shim. Start with frame({op:'compareRender'}) to see what the original enables that the port doesn't."; }
749
+ } else if (samePlatform && logic && !logic.error) {
750
+ verdict = `LOGIC DIVERGED (${logic.matchPct}% RAM match)`;
751
+ nextAction = "The port's logic is NOT tracking the original. Run frame({op:'findDiverge'}) from a known-identical point to get the first frame+address where they split, then breakpoint({on:'write', address}) on each slot to compare the code.";
752
+ } else {
753
+ // cross-platform — RAM diff isn't a logic verdict; lean on render + pixels.
754
+ verdict = "CROSS-PLATFORM PORT";
755
+ nextAction = aliveB
756
+ ? "Both sides render — compare visually with frame({op:'sideBySide'}) and the decoded state with frame({op:'compareRender'}). RAM can't be byte-compared across different hardware."
757
+ : "The port renders blank while the original draws. The graphics shim hasn't enabled output — frame({op:'compareRender'}) shows what to turn on. Verify logic another way (the recompiled CPU should be running even when blank).";
758
+ }
759
+
760
+ return jsonContent({
761
+ op: "portStatus",
762
+ a: { platform: hostA.status.platform, frame: hostA.status.frameCount, renderEnabled: renderA.renderEnabled, pixelsAlive: aliveA },
763
+ b: { platform: hostB.status.platform, frame: hostB.status.frameCount, renderEnabled: renderB.renderEnabled, pixelsAlive: aliveB },
764
+ samePlatform,
765
+ ...(logic ? { logic } : {}),
766
+ verdict,
767
+ nextAction,
768
+ });
769
+ }
770
+
771
+ // Small helper: framebuffer RGBA as the (w,h,rgba) triple pixelSummary wants.
772
+ function rgbaTriple(host) {
773
+ const s = host.screenshotRgba();
774
+ return [s.width, s.height, s.rgba];
775
+ }
776
+
376
777
  async function doStepAndShot({ frames, path: outPath, inline }) {
377
778
  requireImageTarget(outPath, inline, "frame({op:'stepAndShot'})");
378
779
  const host = getHost(sessionKey);
@@ -395,7 +796,7 @@ export function registerFrameTools(server, z, sessionKey) {
395
796
 
396
797
  server.tool(
397
798
  "frame",
398
- "Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'stepInstruction' | 'verify'.\n" +
799
+ "Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'sideBySide' | 'compareRam' | 'findDiverge' | 'compareRender' | 'portStatus' | 'stepInstruction' | 'verify'.\n" +
399
800
  "'step': advance N `frames` as fast as possible — NO pacing/audio/vsync. Cores run at WASM speed, so frames:3600 " +
400
801
  "(1 min of game time) finishes in ~5-30ms, cheaper than a screenshot. Don't be timid — skip a title with 300, a " +
401
802
  "level with 7200; prefer ONE big call.\n" +
@@ -406,6 +807,33 @@ export function registerFrameTools(server, z, sessionKey) {
406
807
  "format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
407
808
  "assertion that costs zero image tokens.**\n" +
408
809
  "'stepAndShot': step + screenshot in ONE round-trip — the drive-then-look loop. (No overlayBoxes/scale here — png only.)\n" +
810
+ "'sideBySide': capture BOTH hosts (slot A + the slot-B comparison host) into ONE composited PNG — A left, B right, " +
811
+ "divider between. The two-cores-in-one-call capture for the original-vs-port compare loop: loadMedia the original " +
812
+ "in slot A, loadMedia({slot:'b'}) the port, then frame({op:'sideBySide', frames}) steps BOTH the same N frames and " +
813
+ "shows them together. Panes are integer-upscaled to a shared height so a handheld next to a console reads at a " +
814
+ "comparable size. Returns per-pane {platform, frame, distinctColors, dominantColor, dominantPct} so a no-vision " +
815
+ "agent still gets a structured 'are both alive / how different' signal. Requires a ROM in slot B (loadMedia({slot:'b'})). " +
816
+ "Same image contract as screenshot (path or inline:true).\n" +
817
+ "'compareRam': the RAM-diff ORACLE — the STATE-level sibling of sideBySide. Compares the work-RAM (`region`, default " +
818
+ "'system_ram') of slot A vs slot B at the same game-moment to prove a logic PORT is correct INDEPENDENT of graphics " +
819
+ "(matching RAM = identical logic even when the port renders blank). Returns a DIGESTED verdict: matchPct, " +
820
+ "identical, and the diverging address RANGES run-length-encoded with a 4-byte sample of each side (NOT raw byte " +
821
+ "dumps) so even a small model gets '$0300-$0312 differ' not two hex blobs. Workflow: state-restore both to an " +
822
+ "identical point, step both N frames, compareRam — the FIRST diverging span is usually the bug. Requires slot B.\n" +
823
+ "'findDiverge': the ROOT-CAUSE finder built ON compareRam — where compareRam says THAT they differ, this says exactly " +
824
+ "WHEN and WHERE. Snapshots both slots, steps them in lockstep up to `maxFrames`, and reports the first frame + first " +
825
+ "byte address at which the work-RAM splits ({atFrame, address, a, b}). Non-destructive: both hosts are RESTORED to " +
826
+ "their pre-search state. Run it from a known-identical point (state-restore both first) so 'first split' is meaningful. " +
827
+ "The agent then disasms the code that writes that address on both sides. Requires slot B.\n" +
828
+ "'compareRender': the PRESENTATION oracle — compare the decoded RENDERING STATE of slot A vs slot B (BG/sprites " +
829
+ "enabled? which tilemap/palette? forced blank?) instead of bytes. This is what an agent building/tuning the graphics " +
830
+ "shim needs: it says in plain terms WHAT the port's presentation is missing vs. the original. Same-platform ports get " +
831
+ "a line diff (onlyInOriginal = your shim's TODO); cross-platform ports get both summaries + each side's renderEnabled " +
832
+ "verdict. Requires slot B.\n" +
833
+ "'portStatus': the CAPSTONE — ONE call that fuses logic (RAM), presentation (render state), and pixels into a single " +
834
+ "'state of your port' verdict + the next concrete action (e.g. 'LOGIC DONE, PRESENTATION MISSING → build the graphics " +
835
+ "shim, start with compareRender'). Use this FIRST when working a port to know what to do next; drill in with the " +
836
+ "specific compare ops. Requires slot B.\n" +
409
837
  "'stepInstruction': execute exactly ONE CPU instruction and stop (finer than 'step'); freezes the CPU one " +
410
838
  "instruction later and returns { pc }. Pair with cpu({op:'read'}) to watch registers change while tracing a routine.\n" +
411
839
  "'verify': one-call 'is the game actually rendering / alive?' health check WITHOUT vision — for the spiral where an " +
@@ -419,8 +847,11 @@ export function registerFrameTools(server, z, sessionKey) {
419
847
  "IMAGE CONTRACT (screenshot/stepAndShot): the image goes to `path` (default, returns {path}) OR inline:true — " +
420
848
  "you MUST pass one. Keeps PNGs out of context unless asked.",
421
849
  {
422
- op: z.enum(["step", "screenshot", "stepAndShot", "stepInstruction", "verify"]).describe("step frames; capture a screenshot; step+capture in one call; single-step one CPU instruction; or verify the game is actually rendering/alive (no vision needed)."),
423
- frames: z.number().int().min(1).max(1_000_000).default(1).describe("op=step/stepAndShot: frames to advance (1-1,000,000). 36000 (10 min) usually completes in <1s — don't be conservative."),
850
+ op: z.enum(["step", "screenshot", "stepAndShot", "sideBySide", "compareRam", "findDiverge", "compareRender", "portStatus", "stepInstruction", "verify"]).describe("step frames; capture a screenshot; step+capture in one call; capture both hosts side-by-side (A|B); compareRam = diff slot-A vs slot-B work-RAM (the logic-port oracle); findDiverge = find the first frame+byte where the two slots split (root-cause finder); compareRender = diff the decoded rendering state of the two slots (the presentation oracle); portStatus = ONE fused 'state of your port' verdict + next action (the capstone); single-step one CPU instruction; or verify the game is actually rendering/alive (no vision needed)."),
851
+ frames: z.number().int().min(1).max(1_000_000).default(1).describe("op=step/stepAndShot/sideBySide/compareRam/compareRender: frames to advance (1-1,000,000). For the slot-A/B compare ops, BOTH hosts step the same amount. 36000 (10 min) usually completes in <1s — don't be conservative."),
852
+ region: z.string().optional().describe("op=compareRam/findDiverge/portStatus: memory region to diff across the two slots (default 'system_ram', the portable work-RAM). Both hosts must expose it."),
853
+ maxRanges: z.number().int().min(1).max(256).default(24).describe("op=compareRam: cap on the diverging address ranges returned (largest first)."),
854
+ maxFrames: z.number().int().min(1).max(100000).default(600).describe("op=findDiverge: max frames to step in lockstep looking for the first divergence (default 600 = ~10s)."),
424
855
  format: z.enum(["png", "ascii"]).default("png").describe("op=screenshot: 'png' (default, real image) or 'ascii' (lossy text render)."),
425
856
  path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
426
857
  inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
@@ -436,6 +867,11 @@ export function registerFrameTools(server, z, sessionKey) {
436
867
  case "step": return doStep(args);
437
868
  case "screenshot": return doScreenshot(args);
438
869
  case "stepAndShot": return doStepAndShot(args);
870
+ case "sideBySide": return await doSideBySide(args);
871
+ case "compareRam": return compareRam(args);
872
+ case "findDiverge": return findDiverge(args);
873
+ case "compareRender": return await compareRender(args);
874
+ case "portStatus": return await portStatus(args);
439
875
  case "stepInstruction": return await stepInstructionCore(sessionKey);
440
876
  case "verify": return await doVerify(args);
441
877
  default: throw new Error(`frame: unknown op '${args.op}'`);
@@ -58,10 +58,39 @@ import { registerCheatTools } from "./cheats.js";
58
58
  import { createDisclosure } from "../disclosure.js";
59
59
  import { jsonContent, safeTool, withClearToolErrors } from "../util.js";
60
60
  import { getHostOrNull, setDisclosure } from "../state.js";
61
+ import { da65Available } from "../../toolchains/cc65/da65.js";
61
62
  import { readFileSync } from "node:fs";
62
63
  import { fileURLToPath } from "node:url";
63
64
  import { dirname, join } from "node:path";
64
65
 
66
+ /**
67
+ * The debug capabilities the LOADED core (and the installed toolchain) actually
68
+ * implement — so an agent picks a working trace strategy UP FRONT instead of
69
+ * discovering the gaps through a string of `notSupported` returns (each a wasted
70
+ * round trip). Capability varies by platform/core build and by whether the
71
+ * cc65/da65 toolchain is installed, so it can't be assumed. Surfaced in
72
+ * catalog({op:'status'}). Names are agent-facing (what you'd reach for), mapped
73
+ * to the host's existing *Supported() probes.
74
+ * @param {import("../../host/index.js").LibretroHost|null} host
75
+ */
76
+ function hostCapabilities(host) {
77
+ const da65 = da65Available();
78
+ if (!host) return { da65Toolchain: da65 };
79
+ const has = (m) => { try { return !!host[m]?.(); } catch { return false; } };
80
+ return {
81
+ pcBreakpoint: has("pcBreakSupported"), // breakpoint({on:'pc'})
82
+ watchpointExact: has("watchpointSupported"), // breakpoint({on:'write', precision:'exact'}) + condition filter
83
+ readWatch: has("readWatchSupported"), // breakpoint({on:'read'})
84
+ rangeWatch: has("vramWatchSupported"), // watch({on:'range'}) (VRAM-port trace)
85
+ registerWrite: has("setRegSupported"), // register write + callSubroutine
86
+ registerSnapshot: has("regSnapSupported"), // registersAtHit on a break
87
+ cheats: has("cheatsSupported"), // cheats({op:'apply'}) via retro_cheat_set
88
+ diskImage: has("diskImageSupported"), // C64 .d64 loadMedia
89
+ keyboard: has("keyboardSupported"), // keyboard input (C64/MSX)
90
+ da65Toolchain: da65, // disasm({target:'rom'/'references'/...})
91
+ };
92
+ }
93
+
65
94
  // Package version — surfaced by catalog({op:'status'}) so an agent can
66
95
  // check the running romdev version with a plain TOOL CALL (works over MCP AND the
67
96
  // HTTP/skill surface), e.g. to detect a saved skill is stale. (GET /healthz also
@@ -190,7 +219,7 @@ export function registerTools(server, z, sessionKey) {
190
219
  "• op:'status' — a snapshot of the current session: which platform's core/ROM is in the running host (if any), current frame count, last-loaded media, loaded categories. Call this when you've lost context across many tool calls and want to re-ground.",
191
220
  {
192
221
  op: z.enum(["categories", "status"]).default("categories")
193
- .describe("categories=tool-category catalog; status=live session snapshot (romdevVersion + host/platform/frameCount/media — call this to check the running version, e.g. is a saved skill stale)."),
222
+ .describe("categories=tool-category catalog; status=live session snapshot (romdevVersion + host/platform/frameCount/media + a `capabilities` map of which debug ops the loaded core/toolchain implement — call this to check the running version or pick a working trace strategy before probing by failure)."),
194
223
  },
195
224
  safeTool(async ({ op = "categories" }) => {
196
225
  if (op === "status") {
@@ -206,6 +235,10 @@ export function registerTools(server, z, sessionKey) {
206
235
  return jsonContent({
207
236
  romdevVersion: PKG_VERSION,
208
237
  ...base,
238
+ // Which debug ops the loaded core + installed toolchain implement, so
239
+ // an agent picks a working trace strategy up front instead of probing
240
+ // by failure (~4 dead calls/session). v0.41.0 feedback #2 (002129).
241
+ capabilities: hostCapabilities(host),
209
242
  playtestWindowOpen: human.windowOpen,
210
243
  ...(human.windowOpen
211
244
  ? {