romdevtools 0.22.0 → 0.23.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 CHANGED
@@ -399,6 +399,36 @@ When `build({output:'run'})` is too coarse, the long-form workflow:
399
399
  5. `input({op:'set'})` / `input({op:'press'})` / `input({op:'sequence'})` to drive the game
400
400
  6. `state({op:'save'}, "checkpoint")` / `state({op:'load'}, "checkpoint")` for try/undo
401
401
 
402
+ ## Diagnosing behavior over time (game-feel, not just "is it alive")
403
+
404
+ `frame({op:'verify'})` answers "is it rendering / alive". When the screen looks
405
+ plausible but the game is WRONG — choppy movement, a value that's off, a piece
406
+ that locks mid-air — STOP eyeballing screenshots and trace the state. These tools
407
+ already exist; reach for them by symptom:
408
+
409
+ - **"Movement/scrolling feels choppy / camera desyncs / scroll jumps."**
410
+ `recordSession({frames:180, holdInputs:[{right:true}], includeScreenshots:false,
411
+ memorySamples:[{region, offset, length, label}]})` — holds input over N frames and
412
+ returns an analyzable timeline. Sample the player's screen-X + the scroll
413
+ registers (Genesis: `genesis_vsram` + the HSCROLL table in `video_ram`; per
414
+ platform varies) and look for camera scroll changing while the sprite barely
415
+ moves, or non-monotone deltas. `watch({on:'mem', format:'series', ranges:[...]})`
416
+ gives the same idea as a compact value-vs-frame CURVE per byte. (Genesis: see
417
+ its MENTAL_MODEL "Why does horizontal movement feel choppy?" — the usual cause
418
+ is rewriting tilemaps in the frame loop; `watch({on:'dma', perFrame:true})`
419
+ shows the per-frame DMA bytes that spike when you do.)
420
+ - **"A computed value is wrong but the build is clean."** Don't re-read your C.
421
+ Resolve the variable's address and read it: `build({output:'romWithDebug',
422
+ resolveSymbols:["grid","score"]})` (or `symbols({op:'resolve', mapPath|dbgPath,
423
+ name})`) → `memory({op:'read', region, offset})`. Cheap, zero image tokens, and
424
+ it tells you whether the bug is your logic or your data. (sm83/z80: a "wrong
425
+ value" is far more often a WRAM layout collision than a miscompile — see the
426
+ GB/GBC SDCC_GOTCHAS "codegen traps in plain game logic".)
427
+ - **"I can't tell what's on the background."** `background({view:'map'})` decodes
428
+ the BG tilemap (grid of tile indices, or a rendered PNG) — don't hand-compute
429
+ nametable offsets. Small handheld too tiny to read inline? `frame({op:'screenshot',
430
+ scale:4})` up-scales (nearest-neighbor).
431
+
402
432
  ## When a call fails: READ THE ERROR FIRST
403
433
 
404
434
  romdev errors are written FOR you — they name what went wrong AND how to recover. Read the message (and `issues[]`) before guessing, screenshotting, or retrying blindly. Two shapes:
package/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.23.0
8
+
9
+ Response to two real build-session feedback reports (a GBC Columns clone + a
10
+ Genesis Uridium POC). Theme: bugs found, false alarms removed, and — the recurring
11
+ finding — **tools that already existed but agents couldn't find them**.
12
+
13
+ ### Fixed — multi-tenant host cross-talk (a whole class of bugs)
14
+ `sprites({op:'inspect', platform:'genesis'})` returned a *GBC*-flavored error while
15
+ Genesis was loaded. Root cause: `inspectSpritesCore` / `getCPUStateCore` /
16
+ `getAudioStateCore` / `inspectBackgroundMapCore` / `inspectPatternTilesCore` were
17
+ module-global `export let` bindings reassigned per session, each closing over THAT
18
+ registration's `sessionKey` — so the last session to register stole the host for
19
+ everyone. Now the caller's `sessionKey` is threaded through. Verified on all 7
20
+ tile-based sprite platforms with sessions live simultaneously.
21
+
22
+ ### Fixed — SMS/GG C crt0: `static x = N;` initializers booted to 0
23
+ Investigating two reported "silent sm83 miscompiles" (32-bit xorshift, indexed
24
+ loop): NEITHER reproduces on GB/GBC — both produce byte-exact output. The real bug
25
+ was a genuine **z80 crt0 defect**: `sms_crt0.s`/`gg_crt0.s` placed `_INITIALIZER`
26
+ in RAM not ROM, so the gsinit `ldir` copied RAM onto itself → every value-static
27
+ booted 0 and BSS wasn't zeroed. Fixed both (mirrors the correct `gb_crt0.s`).
28
+ Re-verified all SMS/GG scaffolds still build clean + render.
29
+
30
+ ### Changed — lint stops crying wolf on WRAM copies
31
+ The SDCC `xdata-copy-miscompile` warning fired on EVERY `dst[i]=src[i]` loop,
32
+ including plain WRAM arrays (the message even said "ignore if plain WRAM") —
33
+ training agents to ignore lint. Now: provably-VRAM dest → warning, plain RAM array
34
+ → suppressed, unknown → info. (Deliberately did NOT add the requested 32-bit-shift
35
+ / short-loop lint heuristics — they'd be false positives for non-bugs.)
36
+
37
+ ### Added — feedback ergonomics
38
+ - **`frame({op:'screenshot', scale})` up-scales** (integer ≥2, nearest-neighbor)
39
+ for legible handheld shots (GB 160×144 → 640×576 at `scale:4`), plus the
40
+ existing `0<scale<1` downscale. All platforms.
41
+ - **Cheap symbol→address:** `symbols({op:'resolve', dbgPath|mapPath, name})` reads
42
+ the map FILE on disk (no 63 KB round-trip through context); `build({output:
43
+ 'romWithDebug', resolveSymbols:[...]})` folds just those addresses into the
44
+ result. cc65 `.dbg` / sdld `.map` / GNU ld `.map`.
45
+
46
+ ### Added — Genesis feel/perf diagnostics
47
+ - **`watch({on:'dma', perFrame:true})`** — per-frame DMA-bytes timeline + spike
48
+ detection (catches "rewriting tilemaps in the frame loop", the #1 Genesis feel
49
+ bug). A hardware-scroll scaffold settles to a flat ~8 bytes/frame.
50
+ - **`scaffold` template `two_plane_parallax`** — plane-A foreground + plane-B
51
+ repeated starfield + player sprite, hardware scroll only, zero loop-time tilemap
52
+ writes. Builds clean, renders, scrolls (verified).
53
+ - Genesis MENTAL_MODEL/TROUBLESHOOTING: "do NOT rewrite tilemaps in the frame
54
+ loop", logical-vs-hardware plane size, the correct parallax loop, Sonic-style
55
+ column streaming, and a "why does movement feel choppy?" recipe.
56
+
57
+ ### Changed — discoverability (the recurring root cause)
58
+ Several feedback "feature asks" were tools that already existed. `recordSession`
59
+ (motion/telemetry timeline) was mislabeled in the catalog as "capture inputs for
60
+ replay"; relabeled. New AGENTS.md "Diagnosing behavior over time (game-feel)"
61
+ section maps symptom → existing tool (choppy movement → `recordSession`/`watch`
62
+ series on scroll+sprite; wrong-but-clean value → `resolveSymbols` + `memory` read;
63
+ can't-read-the-BG → `background({view:'map'})` + `screenshot scale:4`).
64
+
65
+ ### Other
66
+ - `build({output:'project', path})` already defaults the gb/gbc/sms/gg/msx crt0 +
67
+ codeLoc — documented (the GBC agent was hand-passing them to `output:'rom'`).
68
+ - P4 doc + a new info-level `wram-static-overlap` lint advisory (hardcoded
69
+ `$C000–$C0FF` pointer overlapping SDCC's static-data segment — the actual cause
70
+ of the reporter's "monochrome RNG").
71
+
72
+ ## 0.22.1
73
+
74
+ Doc-only follow-up to 0.22.0's movement-analysis feedback: the `pressDuring`
75
+ schema on `watch` and `breakpoint` now states that entries with OVERLAPPING
76
+ windows on the same port are OR'd into a chord (e.g. `b`+`right` held while `a`
77
+ fires mid-window), not overwritten. The driver already behaved this way; this
78
+ documents the guarantee so it doesn't have to be confirmed empirically.
79
+
7
80
  ## 0.22.0
8
81
 
9
82
  **Transparency + correctness pass: every tool failure is actionable, dangerous
@@ -13,7 +13,11 @@
13
13
  * level fits in the scroll plane and plain VDP_setHorizontalScroll moves
14
14
  * it — a real working scroller with no streaming. For a world WIDER than
15
15
  * 512 px you additionally stream the column entering view each time camX
16
- * crosses an 8-px boundary (see MENTAL_MODEL.md "Horizontal scrolling").
16
+ * crosses an 8-px boundary (see MENTAL_MODEL.md "How Sonic-style large
17
+ * maps REALLY work"). For a from-scratch smooth-scroll + parallax
18
+ * starting point with NO per-frame tilemap writes, see the
19
+ * two_plane_parallax template + MENTAL_MODEL.md "Scrolling, parallax &
20
+ * the feel trap".
17
21
  */
18
22
 
19
23
  #include <genesis.h>
@@ -0,0 +1,166 @@
1
+ /* ── two_plane_parallax.c — Genesis SGDK two-plane parallax scaffold ──
2
+ *
3
+ * The "Uridium / Sonic-feel" starting point: a side-scrolling world that
4
+ * moves SMOOTHLY because the frame loop does HARDWARE SCROLL ONLY. There
5
+ * are ZERO tilemap writes inside the loop — the two planes are painted
6
+ * ONCE at setup, and every frame we just nudge two scroll registers and
7
+ * re-stage one sprite. This is the single most important thing to copy:
8
+ *
9
+ * ★ Do NOT rewrite tilemaps in the frame loop. ★
10
+ *
11
+ * Rewriting a plane each frame is a big VDP/DMA burst that overruns
12
+ * vblank and makes movement feel choppy/juddery. Hardware scroll is free.
13
+ *
14
+ * Layout:
15
+ * - Plane A (foreground): a painted "world" — a ground strip + scattered
16
+ * platform blocks across a 512-px (64-cell) plane. Scrolls 1:1 with
17
+ * the camera.
18
+ * - Plane B (background): a repeated starfield filling the whole plane.
19
+ * Scrolls at 1/4 the camera speed for parallax depth (far things
20
+ * move slower). Because Plane B is filled edge-to-edge and the VDP
21
+ * scroll wraps within the plane, it tiles forever with no redraw.
22
+ * - One hardware sprite = the player, drawn in SCREEN space.
23
+ *
24
+ * Drive it: D-pad LEFT/RIGHT scrolls the camera (the player walks within
25
+ * the world). The camera clamps to the world edges.
26
+ *
27
+ * IMPORTANT (logical vs hardware plane size): the Genesis has ONE shared
28
+ * plane-size setting for BOTH planes. We use the default 64x32 cells
29
+ * (512x256 px). A "32-wide level" still lives inside a 64-wide PHYSICAL
30
+ * plane — you don't get an independent per-plane size. See the Genesis
31
+ * MENTAL_MODEL.md "Scrolling, parallax & the feel trap".
32
+ *
33
+ * To go WIDER than 512 px (a true Sonic-size level) you keep this exact
34
+ * loop and add ONE thing: stream the single offscreen column that's about
35
+ * to scroll into view each time the camera crosses an 8-px tile boundary
36
+ * (a circular buffer in the 64-cell plane) — NOT a whole-plane redraw.
37
+ * See MENTAL_MODEL.md "How Sonic-style large maps really work".
38
+ */
39
+
40
+ #include <genesis.h>
41
+
42
+ /* ── Tiles (4bpp, 8 rows x 4 bytes). Indices live at/above TILE_USER_INDEX
43
+ * so they don't clobber SGDK's font + system tiles below it. ── */
44
+ #define T_BLANK (TILE_USER_INDEX + 0)
45
+ #define T_GROUND (TILE_USER_INDEX + 1)
46
+ #define T_BLOCK (TILE_USER_INDEX + 2)
47
+ #define T_PLAYER (TILE_USER_INDEX + 3)
48
+ #define T_STAR (TILE_USER_INDEX + 4)
49
+
50
+ static const u32 tile_blank[8] = { 0,0,0,0,0,0,0,0 };
51
+ /* Solid ground: lit top edge, darker body. */
52
+ static const u32 tile_ground[8] = {
53
+ 0x11111111, 0x12222221, 0x12222221, 0x12222221,
54
+ 0x12222221, 0x12222221, 0x12222221, 0x12222221,
55
+ };
56
+ /* A floating platform block. */
57
+ static const u32 tile_block[8] = {
58
+ 0x33333333, 0x34444443, 0x34444443, 0x34444443,
59
+ 0x34444443, 0x34444443, 0x34444443, 0x33333333,
60
+ };
61
+ /* Player: a chunky diamond so it reads on screen. */
62
+ static const u32 tile_player[8] = {
63
+ 0x00055000, 0x00555500, 0x05555550, 0x55555555,
64
+ 0x55555555, 0x05555550, 0x00555500, 0x00055000,
65
+ };
66
+ /* A single off-center star pixel on a dark field (background twinkle). */
67
+ static const u32 tile_star[8] = {
68
+ 0x00000000, 0x00060000, 0x00000000, 0x00000600,
69
+ 0x00000000, 0x06000000, 0x00000000, 0x00000060,
70
+ };
71
+
72
+ #define PLANE_W_CELLS 64 /* default plane is 64x32 cells */
73
+ #define WORLD_W 512 /* = 64 cells * 8 px (one plane wide) */
74
+ #define SCREEN_W 320 /* H40 */
75
+ #define GROUND_Y 192 /* px — top of the ground strip */
76
+
77
+ /* Static foreground platforms in WORLD PIXEL coords. Painted ONCE. */
78
+ typedef struct { s16 x, y, wcells; } Block;
79
+ static const Block blocks[] = {
80
+ { 48, 152, 5 }, { 160, 128, 4 }, { 256, 160, 6 },
81
+ { 352, 112, 4 }, { 432, 144, 5 },
82
+ };
83
+ #define N_BLOCKS (sizeof(blocks) / sizeof(blocks[0]))
84
+
85
+ /* Player WORLD x exposed for headless motion-trace inspection:
86
+ * symbols({op:'resolve', name:'g_player_x'}) → read it over frames while
87
+ * holding RIGHT and compare against the camera scroll (see MENTAL_MODEL
88
+ * "Why does horizontal movement feel choppy?"). `volatile` keeps it from
89
+ * being optimised into a register so the read resolves. */
90
+ volatile s16 g_player_x = 32; /* world px */
91
+ volatile s16 g_cam_x = 0; /* camera scroll (world px) */
92
+
93
+ int main(bool hard) {
94
+ (void)hard;
95
+
96
+ /* Palettes (BGR, 3 bits/chan). PAL0 = sprite, PAL1 = planes. */
97
+ PAL_setColor(0 + 5, 0x008E); /* player orange (Uridium-ish) */
98
+ PAL_setColor(16 + 1, 0x0A86); /* ground top (light) */
99
+ PAL_setColor(16 + 2, 0x0530); /* ground body (dark) */
100
+ PAL_setColor(16 + 3, 0x0CCC); /* block edge (white) */
101
+ PAL_setColor(16 + 4, 0x0844); /* block body (steel) */
102
+ PAL_setColor(16 + 6, 0x0EEE); /* star (white) */
103
+
104
+ /* Upload all tiles once. */
105
+ VDP_loadTileData(tile_blank, T_BLANK, 1, DMA);
106
+ VDP_loadTileData(tile_ground, T_GROUND, 1, DMA);
107
+ VDP_loadTileData(tile_block, T_BLOCK, 1, DMA);
108
+ VDP_loadTileData(tile_player, T_PLAYER, 1, DMA);
109
+ VDP_loadTileData(tile_star, T_STAR, 1, DMA);
110
+
111
+ /* ── PAINT PLANE B (starfield) ONCE — fills the whole 64x32 plane so
112
+ * it tiles forever as the scroll wraps. NEVER touched again. ── */
113
+ VDP_fillTileMapRect(BG_B, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_STAR),
114
+ 0, 0, PLANE_W_CELLS, 32);
115
+
116
+ /* ── PAINT PLANE A (foreground world) ONCE. ──
117
+ * Ground strip across the whole world width, then the platform blocks
118
+ * on top. After this, the loop does NOT write the tilemap again. */
119
+ VDP_fillTileMapRect(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_GROUND),
120
+ 0, GROUND_Y >> 3, PLANE_W_CELLS, (256 - GROUND_Y) >> 3);
121
+ for (u16 i = 0; i < N_BLOCKS; i++) {
122
+ const Block* b = &blocks[i];
123
+ VDP_fillTileMapRect(BG_A, TILE_ATTR_FULL(PAL1, 0, 0, 0, T_BLOCK),
124
+ b->x >> 3, b->y >> 3, b->wcells, 1);
125
+ }
126
+
127
+ VDP_drawText("PARALLAX D-PAD = SCROLL", 7, 1);
128
+
129
+ const s16 PLAYER_SPEED = 2;
130
+ s16 player_screen_x = SCREEN_W / 2 - 4; /* player stays mid-screen */
131
+
132
+ while (TRUE) {
133
+ u16 pad = JOY_readJoypad(JOY_1);
134
+
135
+ /* Move the player through the WORLD. */
136
+ if (pad & BUTTON_LEFT) g_player_x -= PLAYER_SPEED;
137
+ if (pad & BUTTON_RIGHT) g_player_x += PLAYER_SPEED;
138
+ if (g_player_x < 0) g_player_x = 0;
139
+ if (g_player_x > WORLD_W - 8) g_player_x = WORLD_W - 8;
140
+
141
+ /* Camera centers on the player, clamped to the world edges. */
142
+ s16 cam = g_player_x - (SCREEN_W / 2 - 4);
143
+ if (cam < 0) cam = 0;
144
+ if (cam > WORLD_W - SCREEN_W) cam = WORLD_W - SCREEN_W;
145
+ g_cam_x = cam;
146
+
147
+ /* When the camera is hard against an edge, let the player walk to
148
+ * the screen edge instead of staying pinned to the centre. */
149
+ player_screen_x = g_player_x - cam;
150
+
151
+ /* ★ THE ENTIRE PER-FRAME RENDER COST: two scroll-register writes.
152
+ * Positive cam scrolls the plane LEFT, so we write the negative.
153
+ * Plane A: 1:1 with the world. Plane B: 1/4 speed = parallax. */
154
+ VDP_setHorizontalScroll(BG_A, -cam);
155
+ VDP_setHorizontalScroll(BG_B, -(cam >> 2));
156
+
157
+ /* Re-stage the one player sprite (screen space) + flush the SAT.
158
+ * SPRITE_SIZE(1,1) = 8x8. */
159
+ VDP_setSprite(0, player_screen_x, GROUND_Y - 8, SPRITE_SIZE(1, 1),
160
+ TILE_ATTR_FULL(PAL0, 1, 0, 0, T_PLAYER));
161
+ VDP_updateSprites(1, DMA);
162
+
163
+ SYS_doVBlankProcess();
164
+ }
165
+ return 0;
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -1545,6 +1545,60 @@ export class LibretroHost {
1545
1545
  }
1546
1546
  }
1547
1547
 
1548
+ /**
1549
+ * Per-frame VDP-DMA timeline. Steps `frames` frames ONE AT A TIME, and for
1550
+ * each frame reports how many mem→VDP DMAs fired + how many VRAM/CRAM/VSRAM
1551
+ * bytes they moved. The core's `romdev_dmawatch_set(1)` RESETS its counters
1552
+ * (see the patch), so re-arming before each single-frame step gives a clean
1553
+ * per-frame bucket with no core rebuild — the cheap derivation of "VDP/DMA
1554
+ * work per frame" the feel-diagnostics workflow needs.
1555
+ *
1556
+ * `onFrame(i)` is called at the top of each frame (before the step) so the
1557
+ * caller can drive scheduled input. Genesis-only.
1558
+ *
1559
+ * Returns { frames:[{frame, dmas, words, bytes, romBytes, ramBytes}], ... }.
1560
+ * `romBytes`/`ramBytes` split the moved bytes by source bus (ROM asset upload
1561
+ * vs the RAM→VRAM sprite/scroll refresh) so a per-frame asset-DMA spike — the
1562
+ * "I redrew a tilemap in the loop" smell — stands out from the steady refresh.
1563
+ */
1564
+ watchDmaPerFrame(frames, onFrame) {
1565
+ const mod = this._needMod();
1566
+ this._needMedia();
1567
+ if (!this.dmaWatchSupported()) throw new Error("VDP-DMA watch not supported by this core (Genesis only).");
1568
+ const CAP = 1024;
1569
+ const outPtr = mod._malloc(CAP * 4 * 4);
1570
+ const out2Ptr = mod._malloc(8);
1571
+ const isRam = (src) => (src >>> 0) >= 0xE00000;
1572
+ const out = [];
1573
+ let peakBytes = 0, peakFrame = -1, totalBytes = 0, totalDmas = 0;
1574
+ try {
1575
+ for (let i = 0; i < frames; i++) {
1576
+ if (onFrame) onFrame(i);
1577
+ mod._romdev_dmawatch_set(1); // arm + RESET counters for this frame
1578
+ this._runFramesExclusive(() => false, 1);
1579
+ const n = mod._romdev_dmawatch_get(outPtr, CAP, out2Ptr);
1580
+ const out2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
1581
+ const total = out2[0]; // total DMAs this frame (>= stored)
1582
+ const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 4);
1583
+ let words = 0, romBytes = 0, ramBytes = 0;
1584
+ for (let k = 0; k < n; k++) {
1585
+ const src = u[k * 4 + 1], len = u[k * 4 + 2];
1586
+ words += len;
1587
+ if (isRam(src)) ramBytes += len * 2; else romBytes += len * 2;
1588
+ }
1589
+ const bytes = words * 2;
1590
+ out.push({ frame: i, dmas: total, words, bytes, romBytes, ramBytes,
1591
+ ...(total > n ? { coreBufferTruncated: true } : {}) });
1592
+ totalBytes += bytes; totalDmas += total;
1593
+ if (bytes > peakBytes) { peakBytes = bytes; peakFrame = i; }
1594
+ }
1595
+ mod._romdev_dmawatch_set(0); // disarm
1596
+ return { frames: out, totalBytes, totalDmas, peakBytes, peakFrame };
1597
+ } finally {
1598
+ mod._free(outPtr); mod._free(out2Ptr);
1599
+ }
1600
+ }
1601
+
1548
1602
  pause() {
1549
1603
  this.status.paused = true;
1550
1604
  }
@@ -1630,7 +1684,7 @@ export class LibretroHost {
1630
1684
  sms: { video_ram: "sms_vram (or sms_cram for palette, sms_vdp_regs for VDP regs)" },
1631
1685
  gg: { video_ram: "gg_vram (or gg_cram for the 64-byte 12-bit palette, sms_vdp_regs for VDP regs)" },
1632
1686
  snes: { video_ram: "snes_oam (sprite OAM), snes_cgram (palette), snes_aram (SPC700), or snes_fillram (PPU/DMA reg shadow). The libretro generic 'video_ram' id isn't wired in snes9x." },
1633
- genesis: { video_ram: "genesis_cram / genesis_vsram / genesis_vdp_regs — the generic 'video_ram' id isn't wired in gpgx for Genesis. VRAM itself isn't exposed; use inspectPatternTiles / inspectBackgroundMap / getRenderingContext instead." },
1687
+ genesis: { video_ram: "Genesis VRAM IS exposed via 'video_ram' (gpgx) once a ROM is loaded and a frame has run if it reads empty, step a frame first (the SAT/sprites need the game to have written VRAM). Palette/scroll/VDP regs are genesis_cram / genesis_vsram / genesis_vdp_regs. For decoded views use inspectSprites / inspectPatternTiles / inspectBackgroundMap / getRenderingContext." },
1634
1688
  c64: { video_ram: "c64_color_ram (1 KB) / c64_vic_regs / c64_sid_regs / c64_cia1_regs / c64_cia2_regs. The C64 has no separate VRAM — the VIC-II reads from main system_ram." },
1635
1689
  };
1636
1690
  const hint = suggestions[plat] && suggestions[plat][region];
@@ -113,3 +113,40 @@ export function framebufferToScreenshot(width, height, src, pitch, format) {
113
113
  const buf = framebufferToPng(width, height, src, pitch, format);
114
114
  return { width, height, pngBase64: buf.toString("base64") };
115
115
  }
116
+
117
+ /**
118
+ * Nearest-neighbor resample of a base64 PNG by `scale`. Works BOTH directions:
119
+ * scale<1 → downscale (e.g. 0.5 = half size; ~75% fewer image tokens for a
120
+ * routine "did it change?" sanity check).
121
+ * scale>=2 → integer up-scale (e.g. 4 = 4x size) so tiny handheld targets
122
+ * (GB/GG 160x144, etc.) are legible inline without ImageMagick.
123
+ *
124
+ * Nearest-neighbor (not averaging/smoothing) is deliberate in both directions:
125
+ * it keeps pixel-art edges crisp and palette colors exact, so a scaled shot
126
+ * still reads accurately. The PNG is fully decoded already (it's a tiny
127
+ * framebuffer), so this is cheap. Platform-agnostic — same pixel scaling for
128
+ * every core.
129
+ *
130
+ * @param {string} pngBase64 source PNG, base64-encoded
131
+ * @param {number} scale resample factor (>0)
132
+ * @returns {{ base64: string, width: number, height: number }}
133
+ */
134
+ export function resamplePng(pngBase64, scale) {
135
+ const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
136
+ const dw = Math.max(1, Math.round(src.width * scale));
137
+ const dh = Math.max(1, Math.round(src.height * scale));
138
+ const dst = new PNG({ width: dw, height: dh });
139
+ for (let y = 0; y < dh; y++) {
140
+ const sy = Math.min(src.height - 1, Math.floor(y / scale));
141
+ for (let x = 0; x < dw; x++) {
142
+ const sx = Math.min(src.width - 1, Math.floor(x / scale));
143
+ const si = (sy * src.width + sx) * 4;
144
+ const di = (y * dw + x) * 4;
145
+ dst.data[di] = src.data[si];
146
+ dst.data[di + 1] = src.data[si + 1];
147
+ dst.data[di + 2] = src.data[si + 2];
148
+ dst.data[di + 3] = src.data[si + 3];
149
+ }
150
+ }
151
+ return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
152
+ }
@@ -380,7 +380,7 @@ export function registerAudioTools(server, z, sessionKey) {
380
380
  if (args.frames != null && args.frames > 0) {
381
381
  return await traceAudioChip(sessionKey, args);
382
382
  }
383
- return await getAudioStateCore(args);
383
+ return await getAudioStateCore(args, sessionKey);
384
384
  }
385
385
  if (args.op !== "record") throw new Error(`audioDebug: unknown op '${args.op}'`);
386
386
  if (!args.path) throw new Error("audioDebug({op:'record'}): `path` is required.");
@@ -485,7 +485,7 @@ async function traceAudioChip(sessionKey, { chip, platform, frames, sampleEvery
485
485
 
486
486
  // getAudioStateCore returns jsonContent({...}); pull the structured object.
487
487
  const decode = async () => {
488
- const r = await getAudioStateCore({ chip, platform });
488
+ const r = await getAudioStateCore({ chip, platform }, sessionKey);
489
489
  const txt = r.content?.find?.((c) => c.type === "text")?.text;
490
490
  return txt ? JSON.parse(txt) : {};
491
491
  };
@@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { PNG } from "pngjs";
5
+ import { resamplePng } from "../../host/framebuffer.js";
5
6
  import { getHost } from "../state.js";
6
7
  import { imageContent, jsonContent, safeTool } from "../util.js";
7
8
  import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
@@ -258,30 +259,6 @@ export function registerFrameTools(server, z, sessionKey) {
258
259
  }
259
260
  }
260
261
 
261
- // Nearest-neighbor downscale of a PNG by an integer divisor. Nearest-neighbor
262
- // (not averaging) is deliberate: it keeps pixel-art edges crisp and palette
263
- // colors exact, so a half-size sanity-check shot still reads accurately. The
264
- // PNG is fully decoded already (it's a tiny framebuffer), so this is cheap.
265
- function downscalePng(pngBase64, scale) {
266
- const src = PNG.sync.read(Buffer.from(pngBase64, "base64"));
267
- const dw = Math.max(1, Math.round(src.width * scale));
268
- const dh = Math.max(1, Math.round(src.height * scale));
269
- const dst = new PNG({ width: dw, height: dh });
270
- for (let y = 0; y < dh; y++) {
271
- const sy = Math.min(src.height - 1, Math.floor(y / scale));
272
- for (let x = 0; x < dw; x++) {
273
- const sx = Math.min(src.width - 1, Math.floor(x / scale));
274
- const si = (sy * src.width + sx) * 4;
275
- const di = (y * dw + x) * 4;
276
- dst.data[di] = src.data[si];
277
- dst.data[di + 1] = src.data[si + 1];
278
- dst.data[di + 2] = src.data[si + 2];
279
- dst.data[di + 3] = src.data[si + 3];
280
- }
281
- }
282
- return { base64: PNG.sync.write(dst).toString("base64"), width: dw, height: dh };
283
- }
284
-
285
262
  // PNG capture. Writes to outPath, or returns inline when `inline`.
286
263
  async function shootPng({ path: outPath, inline, overlayBoxes, scale }) {
287
264
  const host = getHost(sessionKey);
@@ -300,16 +277,18 @@ export function registerFrameTools(server, z, sessionKey) {
300
277
  overlayInfo = { platform, spritesDrawn: 0, note: `overlay not yet supported for '${platform}'` };
301
278
  }
302
279
  }
303
- // Downscale AFTER overlay so the boxes scale with the image. scale=1 (or
304
- // unset) is the full-resolution default; a quarter-size shot is ~75%
305
- // fewer image tokens for routine "did it change?" sanity checks.
306
- if (scale && scale < 1) {
307
- const small = downscalePng(pngBase64, scale);
308
- pngBase64 = small.base64; width = small.width; height = small.height;
280
+ // Resample AFTER overlay so the boxes scale with the image. scale=1 (or
281
+ // unset) is the native-resolution default; scale<1 is a downscaled shot
282
+ // (~75% fewer image tokens for routine "did it change?" sanity checks),
283
+ // scale>=2 is an integer up-scale so tiny handheld targets read legibly.
284
+ const scaled = scale && scale !== 1;
285
+ if (scaled) {
286
+ const r = resamplePng(pngBase64, scale);
287
+ pngBase64 = r.base64; width = r.width; height = r.height;
309
288
  }
310
289
  if (!inline) {
311
290
  await writeFile(outPath, Buffer.from(pngBase64, "base64"));
312
- const json = jsonContent({ path: outPath, width, height, ...(scale && scale < 1 ? { scale, fullWidth: shot.width, fullHeight: shot.height } : {}), overlay: overlayInfo });
291
+ const json = jsonContent({ path: outPath, width, height, ...(scaled ? { scale, fullWidth: shot.width, fullHeight: shot.height } : {}), overlay: overlayInfo });
313
292
  json._observerImages = [{ kind: "image", mimeType: "image/png", base64: pngBase64 }];
314
293
  return json;
315
294
  }
@@ -321,7 +300,7 @@ export function registerFrameTools(server, z, sessionKey) {
321
300
  return {
322
301
  content: [
323
302
  imageContent(pngBase64),
324
- { type: "text", text: `framebuffer ${shot.width}x${shot.height}${scale && scale < 1 ? ` (scaled to ${width}x${height})` : ""}${overlayInfo ? ` (overlay: ${overlayInfo.spritesDrawn} sprites)` : ""} — also written to ${tempPath} (use this path for ImageMagick/crops; pass outputPath for a permanent location).` },
303
+ { type: "text", text: `framebuffer ${shot.width}x${shot.height}${scaled ? ` (scaled ${scale}x to ${width}x${height})` : ""}${overlayInfo ? ` (overlay: ${overlayInfo.spritesDrawn} sprites)` : ""} — also written to ${tempPath} (use this path for ImageMagick/crops; pass outputPath for a permanent location).` },
325
304
  ],
326
305
  };
327
306
  }
@@ -414,7 +393,7 @@ export function registerFrameTools(server, z, sessionKey) {
414
393
  "level with 7200; prefer ONE big call.\n" +
415
394
  "'screenshot': capture the latest frame. `format:'png'` (default, exact colors) or `'ascii'` (lossy chafa text " +
416
395
  "render for agents that can't view images). `overlayBoxes` (png) draws a box per visible sprite (SNES+NES only); " +
417
- "`scale` (0<≤1) downscales (~75% fewer image tokens at 0.5); ascii cols/rows/symbols/colors knobs in the param hints. " +
396
+ "`scale` (png) resamples nearest-neighbor BOTH ways: 0<scale<1 DOWNscales (~75% fewer image tokens at 0.5), integer scale≥2 UPscales so tiny handheld targets read inline (e.g. scale:4 → GB 160x144 becomes 640x576); ascii cols/rows/symbols/colors knobs in the param hints. " +
418
397
  "**CHEAP VERIFY: for a binary pass/fail check (theme changed? sprite present? HUD ticked?) prefer scale:0.5 or " +
419
398
  "format:'ascii' — BETTER, read the byte directly: symbols({op:'resolve', name}) → memory({op:'read'}) is a 1-byte " +
420
399
  "assertion that costs zero image tokens.**\n" +
@@ -438,7 +417,7 @@ export function registerFrameTools(server, z, sessionKey) {
438
417
  path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
439
418
  inline: z.boolean().default(false).describe("op=screenshot/stepAndShot: return the image in the response instead of writing to disk."),
440
419
  overlayBoxes: z.boolean().default(false).describe("op=screenshot png: draw a colored bounding box per visible sprite (SNES+NES only)."),
441
- scale: z.number().gt(0).max(1).optional().describe("op=screenshot png: downscale factor (0<scale1, nearest-neighbor). 0.5 ≈ 75% fewer image tokens."),
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); scale≥2 (integer) UPscales (nearest-neighbor — keeps pixel-art crisp) so tiny handheld targets are legible inline, e.g. scale:4 makes a GB 160x144 shot 640x576. scale=1/unset = native resolution."),
442
421
  cols: z.number().int().min(4).max(640).optional().describe("op=screenshot ascii: terminal columns (default fb_width/16)."),
443
422
  rows: z.number().int().min(4).max(480).optional().describe("op=screenshot ascii: terminal rows (default fb_height/16)."),
444
423
  symbols: z.enum(["ascii", "halfblock", "block", "quad", "sextant"]).default("ascii").describe("op=screenshot ascii: chafa symbol set."),
@@ -188,8 +188,8 @@ const CATEGORIES = [
188
188
  },
189
189
  {
190
190
  name: "advanced",
191
- description: "Less common automation: runUntil (drive a ROM headlessly until a condition), watchMemory (cross-platform memory-write tracesee what code touched a RAM byte), runUntilWrite (step until target byte is written, return the PC), record (capture inputs for replay).",
192
- useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "recording an input macro for regression testing"],
191
+ description: "Less common automation + MOTION/TELEMETRY tracing: runUntil (drive a ROM headlessly until a condition), watch({on:'mem', format:'series'}) (a compact value-vs-frame CURVE per byte the primitive for velocity/scroll/sprite-position over time), runUntilWrite (step until target byte is written, return the PC), recordSession (hold/script input over N frames while sampling memory + screenshots into an analyzable timeline — use it to diagnose game-FEEL issues: choppy movement, scroll jumps, camera-vs-sprite desync, NOT just input macros).",
192
+ useWhen: ["want to automate reaching a specific game state", "tracking down which code writes a specific RAM byte (gameplay variable hunting)", "diagnosing why movement/scrolling feels choppy or wrong — sample sprite X + scroll regs over frames with recordSession or watch series", "recording an input macro for regression testing"],
193
193
  register: (s, z, k) => { registerRunUntilTools(s, z, k); registerWatchMemoryTools(s, z, k); registerRecordTools(s, z, k); },
194
194
  },
195
195
  ];
@@ -221,7 +221,7 @@ export function registerMetaSpriteTools(server, z, sessionKey) {
221
221
  },
222
222
  safeTool(async (args) => {
223
223
  switch (args.op) {
224
- case "inspect": return await inspectSpritesCore(args);
224
+ case "inspect": return await inspectSpritesCore(args, sessionKey);
225
225
  case "group": return await spritesGroup(sessionKey, args);
226
226
  case "preview": return await spritesPreview(sessionKey, args);
227
227
  case "capture": return await spritesCapture(sessionKey, { ...args, name: args.name ?? "metasprite" });
@@ -53,7 +53,7 @@ export function registerPlatformTools(server, z, sessionKey) {
53
53
  // inspectPatternTiles lives in the `tiles` tool (tiles({op:'png'}), in
54
54
  // tile-inspect.js) now — extracted here as a live-binding core. Reads the
55
55
  // running emulator's pattern tables / VRAM (or an iNES file via `path`).
56
- inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }) => {
56
+ inspectPatternTilesCore = async ({ platform, path: romPath, bpp = 4, tileBaseByte = 0, paletteBase = 0, paletteIndex = 0, tileCount = 0, scale = 1, outputPath, inline }, callerSessionKey) => {
57
57
  requireImageTarget(outputPath, inline, "inspectPatternTiles");
58
58
  // Integer nearest-neighbor upscale of a PNG — keeps pixel-art tiles crisp
59
59
  // while making a small tile strip actually readable inline.
@@ -102,7 +102,7 @@ export function registerPlatformTools(server, z, sessionKey) {
102
102
  if (!hasChr) throw new Error(`'${romPath}' is a CHR-RAM cart — no graphics in the ROM file. Load it and call inspectPatternTiles without path.`);
103
103
  return emit(png, { platform: p, source: "file", sourcePath: romPath, width, height });
104
104
  }
105
- const host = getHost(sessionKey);
105
+ const host = getHost(callerSessionKey ?? sessionKey);
106
106
  const p = resolvePlatform(host, platform);
107
107
  if (p === "nes") {
108
108
  const { snapshotPatternTables } = await import("../../platforms/nes/ppu.js");
@@ -342,8 +342,8 @@ export function registerPlatformTools(server, z, sessionKey) {
342
342
  };
343
343
 
344
344
  // getCPUState → cpu({op:'read'}) (router in watch-memory.js). Live-binding core.
345
- getCPUStateCore = async ({ platform, cpu = "main" }) => {
346
- const host = getHost(sessionKey);
345
+ getCPUStateCore = async ({ platform, cpu = "main" }, callerSessionKey) => {
346
+ const host = getHost(callerSessionKey ?? sessionKey);
347
347
  const p = resolvePlatform(host, platform);
348
348
  const state = getCPUState(host, p, cpu);
349
349
  if (!state) {
@@ -357,8 +357,8 @@ export function registerPlatformTools(server, z, sessionKey) {
357
357
  // Subsumes the old getDspState / getPsgState / getYm2612State, which
358
358
  // remain as thin deprecated aliases below for back-compat.
359
359
  /** Run the chip decoder for one chip; returns the structured JSON object. */
360
- function readAudioChip(chip) {
361
- const host = getHost(sessionKey);
360
+ function readAudioChip(chip, callerSessionKey) {
361
+ const host = getHost(callerSessionKey ?? sessionKey);
362
362
  if (chip === "dsp") {
363
363
  const p = resolvePlatform(host, "snes");
364
364
  const dsp = getDspState(host, p);
@@ -421,13 +421,20 @@ export function registerPlatformTools(server, z, sessionKey) {
421
421
  throw new Error(`getAudioState: unknown chip '${chip}'. Use 'nes' (NES 2A03), 'gb' (Game Boy/GBC), 'gba' (GBA), 'dsp' (SNES), 'psg' (Genesis/SMS/GG SN76489), 'ym2612' (Genesis FM), 'sid' (C64), or 'mikey' (Lynx).`);
422
422
  }
423
423
 
424
- getAudioStateCore = async ({ chip }) => jsonContent(readAudioChip(chip));
424
+ getAudioStateCore = async ({ chip }, callerSessionKey) => jsonContent(readAudioChip(chip, callerSessionKey));
425
425
 
426
426
  // inspectSprites lives in the `sprites` tool (metasprite-tools.js) now —
427
427
  // extracted here as a live-binding core so the router can call it without
428
428
  // disturbing the other handlers registerPlatformTools owns.
429
- inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }) => {
430
- const host = getHost(sessionKey);
429
+ inspectSpritesCore = async ({ platform, maxSlots, slots, outputPath, inline }, callerSessionKey) => {
430
+ // sessionKey MUST come from the live call, not the closure: these *Core
431
+ // functions are module-level `export let` bindings reassigned on every
432
+ // registerPlatformTools() run, so the closure's `sessionKey` is whatever
433
+ // session registered LAST — not the caller's. Threading it through the
434
+ // call args keeps each session reading its OWN host. (Same fix shape as
435
+ // inspectPaletteCore.) Falls back to the registration key for any caller
436
+ // that still invokes the old 1-arg form.
437
+ const host = getHost(callerSessionKey ?? sessionKey);
431
438
  const p = resolvePlatform(host, platform);
432
439
  // Generic slot filter, applied by each platform branch before returning.
433
440
  // `slots` (explicit index list) wins over `maxSlots` (first N); the
@@ -693,8 +700,8 @@ export function registerPlatformTools(server, z, sessionKey) {
693
700
  // inspectBackgroundMap lives in the `background` tool (rendering-context.js)
694
701
  // now — extracted here as a live-binding core so the router can call it
695
702
  // without disturbing the other handlers registerPlatformTools owns.
696
- inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }) => {
697
- const host = getHost(sessionKey);
703
+ inspectBackgroundMapCore = async ({ platform, render, region, attributesOnly, tilesOnly, which, window, plane, tilemapBaseByte, tileBaseByte, bpp, mapWidth, mapHeight, outputPath, inline }, callerSessionKey) => {
704
+ const host = getHost(callerSessionKey ?? sessionKey);
698
705
  const p = resolvePlatform(host, platform);
699
706
  if (attributesOnly && tilesOnly) {
700
707
  throw new Error("inspectBackgroundMap: attributesOnly and tilesOnly are mutually exclusive — omit both to get tiles + subPaletteGrid together.");