romdevtools 0.40.2 → 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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +144 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/analysis/analyze.js +320 -48
- package/src/analysis/backtrace.js +198 -0
- package/src/analysis/nes-ppu-runtime.js +220 -0
- package/src/analysis/nes-ppu-shim.js +263 -0
- package/src/analysis/pointer-table.js +84 -0
- package/src/analysis/recompile-65816.js +584 -0
- package/src/analysis/rizin.js +13 -1
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/capabilities.js +218 -0
- package/src/cores/registry.js +43 -51
- package/src/mcp/state.js +91 -2
- package/src/mcp/tools/cheats.js +10 -1
- package/src/mcp/tools/disasm.js +331 -24
- package/src/mcp/tools/find-references.js +93 -2
- package/src/mcp/tools/frame.js +440 -4
- package/src/mcp/tools/index.js +34 -1
- package/src/mcp/tools/lifecycle.js +53 -24
- package/src/mcp/tools/memory.js +1 -1
- package/src/mcp/tools/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +9 -6
- package/src/mcp/tools/watch-memory.js +231 -11
- package/src/mcp/util.js +82 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/_worker/pool.js +41 -3
- package/src/toolchains/cc65/da65.js +7 -0
- package/src/cores/wasm/bluemsx_libretro.js +0 -2
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +0 -2
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +0 -2
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +0 -2
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +0 -2
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +0 -2
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +0 -2
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +0 -2
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +0 -2
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +0 -2
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
package/src/mcp/tools/frame.js
CHANGED
|
@@ -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}'`);
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -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
|
|
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
|
? {
|