romdevtools 0.13.0 → 0.14.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.
@@ -19,7 +19,7 @@ import { jsonContent } from "../util.js";
19
19
  import { decodeDMASource } from "../../platforms/genesis/vdp.js";
20
20
  import { makePressDriver } from "./watch-memory.js";
21
21
 
22
- // traceVramSource → dmaTrace({precision:'sampled'}) (router in watch-memory.js).
22
+ // traceVramSource → watch({on:'dma', precision:'sampled'}) (router in watch-memory.js).
23
23
  // Exported core; the router passes its own sessionKey.
24
24
  export async function traceVramSourceCore({ frames = 120, pressDuring, romPreviewBytes = 16, minLengthBytes = 0, sessionKey }) {
25
25
  const host = getHost(sessionKey);
@@ -76,6 +76,6 @@ export async function traceVramSourceCore({ frames = 120, pressDuring, romPrevie
76
76
  });
77
77
  }
78
78
 
79
- // traceVramSource is registered as dmaTrace({precision:'sampled'}) by the
80
- // `dmaTrace` router in watch-memory.js (which imports traceVramSourceCore).
79
+ // traceVramSource is reached via watch({on:'dma', precision:'sampled'}) by the
80
+ // `watch` tool in watch-memory.js (which imports traceVramSourceCore).
81
81
  export function registerTraceVramSourceTools() {}
@@ -881,10 +881,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
881
881
  "Extras: `ranges:[{region,offset,length,label}]` watches MANY disjoint regions in ONE pass (identical frames); `onChange:'reset'|'increase'|'decrease'|'any'` edge filter (reset = counter-reload = the note-onset signal); `valueFilter:{min,max}`; `format:'series'` = compact columnar value-vs-frame curve (~10× smaller for a ramp); `sampleEvery`; `groupByPC` (collapse by sampled PC); `cheatLabels` (auto-name addresses from the cheat DB); `outputPath` streams all events as NDJSON; `stopOnFirst` exits on the first match. " +
882
882
  "**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
883
883
  "• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.)\n" +
884
- "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs.",
884
+ "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs.\n" +
885
+ "• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). On non-Genesis cores returns `notSupported`.",
885
886
  {
886
- on: z.enum(["mem", "range", "pc"])
887
- .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]."),
887
+ on: z.enum(["mem", "range", "pc", "dma"])
888
+ .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace."),
888
889
  // on:'mem'
889
890
  region: z.enum(MEMORY_REGIONS).optional().describe("on:'mem' single-range — the region to watch (same canonical set memory uses, incl. nes_apu_regs, genesis_ym2612, c64_sid_regs). Omit when using `ranges`."),
890
891
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
@@ -907,12 +908,20 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
907
908
  frames: z.number().int().min(1).max(1_000_000).default(600).describe("Frames to run while logging (default 600). on:'range'/'pc' windows are usually short (~120) — pass a smaller value to keep the ring buffer from overflowing."),
908
909
  limit: z.number().int().min(1).max(4000).default(200).describe("on:'range'/'pc' — max events/PCs returned (default 200; full count is in `total`)."),
909
910
  outputPath: z.string().optional().describe("on:'mem' — stream every filter-passing event to this path as NDJSON + return a compact summary. Use for long watches so the full log never enters your context."),
911
+ // on:'dma' (Genesis VDP DMA trace)
912
+ precision: z.enum(["exact", "sampled"]).default("exact").describe("on:'dma' — exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic)."),
913
+ vramDest: z.number().int().min(0).optional().describe("on:'dma' precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
914
+ destWindow: z.number().int().min(0).default(0x40).describe("on:'dma' precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
915
+ dedupe: z.boolean().default(true).describe("on:'dma' precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
916
+ sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("on:'dma' precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
917
+ romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("on:'dma' — bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
918
+ minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("on:'dma' precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
910
919
  pressDuring: z.array(z.object({
911
920
  frame: z.number().int().min(0),
912
921
  button: z.string(),
913
922
  port: z.number().int().min(0).max(3).default(0),
914
923
  holdFrames: z.number().int().min(1).default(2),
915
- })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range)."),
924
+ })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma')."),
916
925
  },
917
926
  safeTool(async (args) => {
918
927
  switch (args.on) {
@@ -925,19 +934,27 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
925
934
  if (args.start == null || args.end == null) throw new Error("watch({on:'pc'}): `start` and `end` are required.");
926
935
  return await wLogPC({ ...args, frames: args.frames ?? 120, limit: args.limit ?? 512 });
927
936
  }
937
+ case "dma": {
938
+ const a = { ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 };
939
+ if (a.precision === "sampled") {
940
+ return await traceVramSourceCore({ ...a, romPreviewBytes: a.romPreviewBytes || 16, sessionKey });
941
+ }
942
+ return await dmaExact(a);
943
+ }
928
944
  default: throw new Error(`watch: unknown on '${args.on}'`);
929
945
  }
930
946
  }),
931
947
  );
932
948
 
933
- // ── dmaTrace (item 3, Genesis only) ─────────────────────────────────────────
949
+ // ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
934
950
  // precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
935
- // traceVramSourceCore (frame-sampled, dest-agnostic).
951
+ // traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`
952
+ // tool's switch (case 'dma'); folded in from the old standalone dmaTrace tool.
936
953
  async function dmaExact({ frames = 120, vramDest, destWindow = 0x40, dedupe = true, sourceFilter = "all", pressDuring, romPreviewBytes = 0, limit = 200 }) {
937
954
  const host = getHost(sessionKey);
938
955
  if (!host.dmaWatchSupported || !host.dmaWatchSupported()) {
939
956
  return jsonContent({ notSupported: true, dmas: [],
940
- note: "dmaTrace is Genesis-only (VDP DMA). On other platforms use breakpoint({on:'write'}) (CPU writes) or the platform's source tracer." });
957
+ note: "watch({on:'dma'}) is Genesis-only (VDP DMA). On other platforms use breakpoint({on:'write'}) (CPU writes) or the platform's source tracer." });
941
958
  }
942
959
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
943
960
  const pressDriver = makePressDriver(host, presses);
@@ -994,43 +1011,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
994
1011
  (totalDistinct > limit ? `Showing ${out.length}/${totalDistinct} distinct — raise limit or narrow vramDest.` : ""),
995
1012
  }), host);
996
1013
  }
997
-
998
- server.tool(
999
- "dmaTrace",
1000
- "GENESIS ONLY — trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — " +
1001
- "WHERE in ROM?', which breakpoint({on:'write'}) can't catch). Keyed by `precision`.\n" +
1002
- "• precision:'exact' (default) — log every mem→VDP DMA with its VRAM DESTINATION, ROM SOURCE, length, and code. " +
1003
- "Filter by `vramDest` (±`destWindow`) to find the exact source of a specific tile. `dedupe` collapses the per-frame " +
1004
- "refresh (7000 events → a handful); `sourceFilter:'rom-only'` drops the RAM→VRAM sprite/scroll noise. " +
1005
- "**Catches a second DMA in the same frame that the sampled mode misses.**\n" +
1006
- "• precision:'sampled' — the cheap frame-sampled tracer: reads the VDP DMA source registers ($15-$17) once per frame " +
1007
- "and logs each DISTINCT mem→VRAM source as a ROM byte offset. **HONEST LIMIT: two DMAs in the SAME frame may show only " +
1008
- "one source — narrow the window around when the graphic appears. dest-agnostic (no vramDest filter).**",
1009
- {
1010
- precision: z.enum(["exact", "sampled"]).default("exact")
1011
- .describe("exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic)."),
1012
- frames: z.number().int().min(1).max(6000).default(120).describe("Frames to step while tracing (default 120 ≈ 2s)."),
1013
- pressDuring: z.array(z.object({
1014
- frame: z.number().int().min(0),
1015
- button: z.string(),
1016
- port: z.number().int().min(0).max(3).default(0),
1017
- holdFrames: z.number().int().min(1).default(2),
1018
- })).optional().describe("Drive input to the screen that uploads the graphic."),
1019
- romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("Bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
1020
- // precision:'exact' only
1021
- vramDest: z.number().int().min(0).optional().describe("precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
1022
- destWindow: z.number().int().min(0).default(0x40).describe("precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
1023
- dedupe: z.boolean().default(true).describe("precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
1024
- sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
1025
- limit: z.number().int().min(1).max(2000).default(200).describe("precision:'exact' — max DMA entries to return (after dedupe/filter)."),
1026
- // precision:'sampled' only
1027
- minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
1028
- },
1029
- safeTool(async (args) => {
1030
- if (args.precision === "sampled") {
1031
- return await traceVramSourceCore({ ...args, romPreviewBytes: args.romPreviewBytes || 16, sessionKey });
1032
- }
1033
- return await dmaExact(args);
1034
- }),
1035
- );
1014
+ // dmaExact + traceVramSourceCore are reached via watch({on:'dma'}) above —
1015
+ // dmaTrace was folded into `watch` (it's a log-all VDP-DMA trace, same family
1016
+ // as on:'mem'/'range'/'pc'), so there's no separate top-level tool.
1036
1017
  }
@@ -20,7 +20,12 @@
20
20
  }
21
21
  header h1 { margin: 0; font-size: 14px; font-weight: 600; }
22
22
  #version { font-size: 11px; font-weight: 400; color: #888; }
23
- #status { font-size: 11px; color: #888; }
23
+ header a.doclink {
24
+ font-size: 11px; color: #6cb6ff; text-decoration: none;
25
+ border: 1px solid #2a3f55; border-radius: 3px; padding: 2px 7px;
26
+ }
27
+ header a.doclink:hover { background: #14202e; text-decoration: underline; }
28
+ #status { font-size: 11px; color: #888; margin-left: auto; }
24
29
  #status.connected { color: #4caf50; }
25
30
  #status.disconnected { color: #f44336; }
26
31
  #tabs {
@@ -172,6 +177,7 @@
172
177
  <body>
173
178
  <header>
174
179
  <h1>romdev /livestream <span id="version">__ROMDEV_VERSION__</span></h1>
180
+ <a class="doclink" href="/documentation" target="_blank" rel="noopener">API docs ↗</a>
175
181
  <span id="status" class="disconnected">disconnected</span>
176
182
  </header>
177
183
  <div id="tabs"></div>
@@ -56,7 +56,7 @@ font-rendered from an ASCII string. Patching the ASCII string then does nothing.
56
56
  string. Do not patch any ASCII string you found; it isn't the source.
57
57
  2. If it IS font-rendered, find the string with `text({op:'find'})` /
58
58
  `text({op:'encode'})` and patch that.
59
- 3. To find where a graphic/text was sourced from: on **Genesis**, `dmaTrace({precision:'sampled'})`
59
+ 3. To find where a graphic/text was sourced from: on **Genesis**, `watch({on:'dma', precision:'sampled'})`
60
60
  — drive to the screen that shows the graphic, and it reports the ROM offset(s)
61
61
  the tiles were DMA'd from (decoded from the VDP DMA registers). Edit the tile
62
62
  bitmaps at that offset, not any string. (Elsewhere: if `breakpoint({on:'write'})` on the VRAM
@@ -198,8 +198,8 @@ Breakpoints are great once you KNOW the address. To FIND it:
198
198
  - **`watch({on:'pc', start, end, frames})`** — coverage trace: every DISTINCT PC that
199
199
  EXECUTED in an address window. "What code runs in this bank during the scoreboard
200
200
  draw?" → `disasm({target:'rom'})` the PCs it returns.
201
- - **`dmaTrace({precision:'exact', vramDest})`** (Genesis) — which DMA wrote the tile at a VRAM dest,
202
- and the ROM SOURCE it came from. The targeted version of `dmaTrace({precision:'sampled'})`; the
201
+ - **`watch({on:'dma', precision:'exact', vramDest})`** (Genesis) — which DMA wrote the tile at a VRAM dest,
202
+ and the ROM SOURCE it came from. The targeted version of `watch({on:'dma', precision:'sampled'})`; the
203
203
  way to catch a DMA'd (not CPU-written) name/portrait bitmap `breakpoint({on:'write'})` can't see.
204
204
 
205
205
  ---
@@ -238,8 +238,8 @@ transition.
238
238
  | Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
239
239
  | Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
240
240
  | FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
241
- | Which DMA wrote a VRAM tile + its source (Genesis) | `dmaTrace({precision:'exact', vramDest})` |
242
- | Where did a VRAM graphic come from (Genesis) | `dmaTrace({precision:'sampled'})` (ROM offset of the DMA source) |
241
+ | Which DMA wrote a VRAM tile + its source (Genesis) | `watch({on:'dma', precision:'exact', vramDest})` |
242
+ | Where did a VRAM graphic come from (Genesis) | `watch({on:'dma', precision:'sampled'})` (ROM offset of the DMA source) |
243
243
  | Drive a menu fast | `input({op:'navigate'})` (advances on screen change) |
244
244
  | Free RAM map for a known game | `cheats({op:'lookup'})` / `cheats({op:'search'})` |
245
245
  | Safe patch | `romPatch({op:'write'})`/`romPatch({op:'writeMany'})` with `expect` |
@@ -20,7 +20,7 @@ check these first. All five have shipped fixes in the bundled runtime
20
20
  classic white-screen: a stray $FF pad there trips CGB mode on a DMG
21
21
  ROM so BGP/OBP* writes are ignored. Because the build sets it from the
22
22
  platform you chose, a freshly built ROM is correct with **no manual
23
- step**. Call `patchGbHeader` only to fix up an existing/external ROM
23
+ step**. Call `romPatch({op:'gbHeader'})` only to fix up an existing/external ROM
24
24
  or override a field (title, cart type, ROM/RAM size, CGB flag).
25
25
 
26
26
  2. **OAM shadow buffer must be page-aligned.** OAM DMA copies 160 bytes
@@ -122,7 +122,7 @@ running a CGB-aware ROM, the DMG registers are ignored.
122
122
  You normally don't touch this byte by hand: `build({output:'rom'})` / `build({output:'run'})`
123
123
  set it from the platform you build for ($00 for `platform:"gb"`, $80/$C0
124
124
  for `platform:"gbc"`). To force a value, set it in your `gb_crt0.s`
125
- header section, or call `patchGbHeader({path, cgb:true})` on the built
125
+ header section, or call `romPatch({op:'gbHeader', path, cgb:true})` on the built
126
126
  ROM (it auto-detects the `.gbc` extension; the standalone
127
127
  `patch-header.js` script does the same).
128
128
 
@@ -201,7 +201,7 @@ Build calls explicitly reference these files via `sourcesPaths` /
201
201
  `includePaths` / `crt0Path` + `codeLoc: 0x150`. `build({output:'rom'})` /
202
202
  `build({output:'run'})` then fix up the cart header automatically (logo, checksums,
203
203
  CGB flag), so the ROM loads under gambatte with no extra step. Use
204
- `patchGbHeader({path})` (MCP tool) or `node patch-header.js <rom>` (CLI)
204
+ `romPatch({op:'gbHeader', path})` (romdev tool) or `node patch-header.js <rom>` (CLI)
205
205
  only on a ROM the build pipeline didn't produce. See your project's
206
206
  README for the exact incantation.
207
207
 
@@ -92,7 +92,7 @@ the cross-platform note: [[sdcc-uint8-loop-bound-trap]].
92
92
  `platform:"gb"` and it stays $00 (DMG). So if colors are wrong, first
93
93
  check you didn't build this as a `.gb` ROM — rebuild with
94
94
  `platform:"gbc"`. (To force a value on an existing ROM: set it in your
95
- `gb_crt0.s` header section, run `patchGbHeader({path:"out.gbc"})`, or
95
+ `gb_crt0.s` header section, run `romPatch({op:'gbHeader', path:"out.gbc"})`, or
96
96
  run `node patch-header.js out.gbc`.) Verify:
97
97
  ```sh
98
98
  xxd -s 0x143 -l 1 out.gbc # expect: 80
@@ -36,7 +36,7 @@ upstream README. Songs are exported from hUGETracker
36
36
  - "OAM DMA wedges sprites" → see `MENTAL_MODEL.md` § R26 footguns +
37
37
  `gb_runtime.c` `oam_dma_copy` implementation
38
38
  - "BGP write does nothing" → check $0143 (CGB flag) via
39
- `patchGbHeader` + Pan Docs § "The Cartridge Header"
39
+ `romPatch({op:'gbHeader'})` + Pan Docs § "The Cartridge Header"
40
40
  - "How does hUGEDriver process a song row?" → `hUGEDriver.c`
41
41
  `hUGE_dosound` body — fully readable
42
42
  - "Why is gambatte refusing my ROM?" → check the header, then
@@ -23,12 +23,12 @@ run rgbfix on the linked GB/GBC ROM — valid Nintendo logo at $0104,
23
23
  header checksum at $014D, global checksum at $014E, cartridge-type /
24
24
  RAM-size bytes, and the CGB flag at $0143 ($00 for `.gb`, $80/$C0 for
25
25
  `.gbc`). A freshly built ROM boots on hardware and strict cores with
26
- **no extra step** — you do not call `patchGbHeader` after a normal build.
26
+ **no extra step** — you do not call `romPatch({op:'gbHeader'})` after a normal build.
27
27
 
28
28
  Reach for header tooling only when working with a ROM the build pipeline
29
29
  didn't produce, or to override a field:
30
30
 
31
- - `patchGbHeader({path: "out.gb"})` — MCP tool.
31
+ - `romPatch({op:'gbHeader', path: "out.gb"})` — romdev tool.
32
32
  Fixes up / overrides the header of an existing ROM on disk (title, cart
33
33
  type, ROM/RAM size, CGB flag, etc.).
34
34
  - `node patch-header.js out.gb` — standalone Node script, copied into
@@ -110,7 +110,7 @@ If you need to write a custom VRAM block-copy:
110
110
 
111
111
  This is independent of the R26 OAM-alignment fix (`shadow_oam __at
112
112
  (0xC100)`) and the header CGB-flag fix (now applied automatically by
113
- `build({output:'rom'})` / `build({output:'run'})`, not a manual `patchGbHeader` step). All
113
+ `build({output:'rom'})` / `build({output:'run'})`, not a manual `romPatch({op:'gbHeader'})` step). All
114
114
  three are silent-failure bugs that look like "did my changes even
115
115
  land?" and need different fixes.
116
116
 
@@ -16,7 +16,7 @@ the same wall.
16
16
  `build({output:'rom'})` / `build({output:'run'})` do this for you at build time: every byte
17
17
  at $0134..$014C is filled, and on `platform:"gbc"` the CGB flag at
18
18
  $0143 is set to $80 (CGB-aware + DMG-compatible). You do **not** run
19
- `patchGbHeader` on a freshly built ROM. Reach for `patchGbHeader` only
19
+ `romPatch({op:'gbHeader'})` on a freshly built ROM. Reach for `romPatch({op:'gbHeader'})` only
20
20
  to fix up an existing / externally built ROM whose header was never
21
21
  set, or to override a field — e.g. starting from a `.gb` ROM and
22
22
  wanting CGB color, pass `cgb: true` explicitly.
@@ -161,11 +161,11 @@ the only differences at build time are:
161
161
 
162
162
  - ROM extension: `.gbc` (vs `.gb`)
163
163
  - the build sets `$0143 = $80` to flip CGB mode on (automatic when you
164
- build with `platform:"gbc"` — no manual `patchGbHeader` step)
164
+ build with `platform:"gbc"` — no manual `romPatch({op:'gbHeader'})` step)
165
165
  - gambatte core accepts both DMG + CGB-mode ROMs
166
166
 
167
167
  For new GBC code that wants to be CGB-only (no DMG fallback) set the
168
- CGB byte to `$C0` instead of `$80` — `patchGbHeader({path, cgb:true})`
168
+ CGB byte to `$C0` instead of `$80` — `romPatch({op:'gbHeader', path, cgb:true})`
169
169
  on the built ROM can override it.
170
170
 
171
171
  ## Horizontal scrolling (for side-scrollers)
@@ -59,7 +59,7 @@ and falls back to DMG mode when it's `$00`.
59
59
  When you build with `platform:"gbc"`, `build({output:'rom'})` / `build({output:'run'})`
60
60
  **auto-fix the header** — Nintendo logo, header + global checksums,
61
61
  and `$0143 = $80` (CGB-enhanced) — so a freshly built `.gbc` already
62
- boots in color. You do **not** call `patchGbHeader` for that.
62
+ boots in color. You do **not** call `romPatch({op:'gbHeader'})` for that.
63
63
 
64
64
  ```js
65
65
  build({ output: 'run', platform: "gbc", language: "c", ... }); /* header auto-fixed */
@@ -67,7 +67,7 @@ build({ output: 'run', platform: "gbc", language: "c", ... }); /* header auto-f
67
67
 
68
68
  If you instead see green-shade DMG mode, the ROM was almost certainly
69
69
  built with `platform:"gb"` (so the CGB flag stayed `$00`). Rebuild with
70
- `platform:"gbc"`. Reach for `patchGbHeader` only to fix up an existing /
70
+ `platform:"gbc"`. Reach for `romPatch({op:'gbHeader'})` only to fix up an existing /
71
71
  externally built `.gbc` whose header was never set, or to override a
72
72
  header field (e.g. force `cgb:false`).
73
73
 
@@ -106,12 +106,12 @@ Without the attribute writes, every BG tile defaults to palette 0.
106
106
  ## "Game ran on Game Boy emulator but not on Game Boy Color emulator"
107
107
 
108
108
  `loadMedia({platform:"gbc", path})` expects gambatte in CGB mode. If
109
- your ROM was built with `platform:"gb"` (no patchGbHeader) the file
109
+ your ROM was built with `platform:"gb"` (no gbHeader patch) the file
110
110
  extension is `.gb` and the header CGB byte is $00, so gambatte starts
111
111
  in DMG mode. To switch a DMG ROM to CGB:
112
112
 
113
113
  1. Rename / re-extension to `.gbc`
114
- 2. Run `patchGbHeader({path:"out.gbc"})` — also fixes the global
114
+ 2. Run `romPatch({op:'gbHeader', path:"out.gbc"})` — also fixes the global
115
115
  checksum that the boot ROM checks
116
116
 
117
117
  ## "Sound is the same as DMG"
@@ -125,7 +125,7 @@ sound channels or extra waveforms.
125
125
  The bundled GBC scaffolds all fit in 32 KB (single bank, no MBC).
126
126
  For larger projects use an MBC (memory bank controller). MBC1 / MBC3
127
127
  work in gambatte; set the `$0147` cartridge type byte accordingly.
128
- patchGbHeader doesn't set this — you write it from your asm/C.
128
+ romPatch({op:'gbHeader'}) doesn't set this — you write it from your asm/C.
129
129
 
130
130
  ## "Frame heartbeat feels janky / slow"
131
131
 
@@ -7,7 +7,7 @@ the color-aware scaffolds; everything below is in lockstep with
7
7
  the GB tree.
8
8
 
9
9
  CGB-specific:
10
- - `patchGbHeader({cgb: true})` sets $0143 = $80 → gambatte boots
10
+ - `romPatch({op:'gbHeader', cgb: true})` sets $0143 = $80 → gambatte boots
11
11
  in CGB mode with color palette RAM active
12
12
  - VRAM bank 1 (selected via VBK = $FF4F) holds per-tile attribute
13
13
  bytes (palette index, H/V flip, BG-OAM priority)
@@ -53,7 +53,7 @@ upstream README. Songs are exported from hUGETracker
53
53
  - "OAM DMA wedges sprites" → see `MENTAL_MODEL.md` § R26 footguns +
54
54
  `gb_runtime.c` `oam_dma_copy` implementation
55
55
  - "BGP write does nothing" → check $0143 (CGB flag) via
56
- `patchGbHeader` + Pan Docs § "The Cartridge Header"
56
+ `romPatch({op:'gbHeader'})` + Pan Docs § "The Cartridge Header"
57
57
  - "How does hUGEDriver process a song row?" → `hUGEDriver.c`
58
58
  `hUGE_dosound` body — fully readable
59
59
  - "Why is gambatte refusing my ROM?" → check the header, then
@@ -23,12 +23,12 @@ run rgbfix on the linked GB/GBC ROM — valid Nintendo logo at $0104,
23
23
  header checksum at $014D, global checksum at $014E, cartridge-type /
24
24
  RAM-size bytes, and the CGB flag at $0143 ($80/$C0 for `.gbc`, $00 for
25
25
  `.gb`). A freshly built ROM boots on hardware and strict cores with **no
26
- extra step** — you do not call `patchGbHeader` after a normal build.
26
+ extra step** — you do not call `romPatch({op:'gbHeader'})` after a normal build.
27
27
 
28
28
  Reach for header tooling only when working with a ROM the build pipeline
29
29
  didn't produce, or to override a field:
30
30
 
31
- - `patchGbHeader({path: "out.gb"})` — MCP tool.
31
+ - `romPatch({op:'gbHeader', path: "out.gb"})` — romdev tool.
32
32
  Fixes up / overrides the header of an existing ROM on disk (title, cart
33
33
  type, ROM/RAM size, CGB flag, etc.).
34
34
  - `node patch-header.js out.gb` — standalone Node script, copied into
@@ -110,7 +110,7 @@ If you need to write a custom VRAM block-copy:
110
110
 
111
111
  This is independent of the R26 OAM-alignment fix (`shadow_oam __at
112
112
  (0xC100)`) and the header CGB-flag fix (now applied automatically by
113
- `build({output:'rom'})` / `build({output:'run'})`, not a manual `patchGbHeader` step). All
113
+ `build({output:'rom'})` / `build({output:'run'})`, not a manual `romPatch({op:'gbHeader'})` step). All
114
114
  three are silent-failure bugs that look like "did my changes even
115
115
  land?" and need different fixes.
116
116
 
@@ -148,8 +148,33 @@ async function getSdl() {
148
148
  try {
149
149
  const ns = await import("@kmamal/sdl");
150
150
  _sdlModule = ns.default || ns;
151
+ // GROUND-TRUTH visibility check (cross-platform, NOT env-var guessing):
152
+ // SDL picks a video driver at init. With no presentable surface (no desktop
153
+ // session, no Xvfb, headless box) it falls back to "offscreen"/"dummy" —
154
+ // createWindow then SUCCEEDS and audio plays, but nothing appears on any
155
+ // physical screen. That's the silent "agent says the window's up, user sees
156
+ // nothing (but hears sound)" failure. We catch it HERE by asking SDL which
157
+ // driver it actually selected — works the same on Linux/macOS/Windows, and
158
+ // correctly ALLOWS a real offscreen X server (Xvfb reports "x11", not
159
+ // "offscreen"). Headless rendering (screenshot/runSource) never calls this,
160
+ // so offscreen stays perfectly fine for everything except opening a window
161
+ // for a human.
162
+ const driver = _sdlModule?.info?.drivers?.video?.current;
163
+ if (driver === "offscreen" || driver === "dummy") {
164
+ throw tag(new Error(
165
+ `SDL selected the "${driver}" video driver — there is no presentable display, ` +
166
+ "so a playtest window would render but never appear on a physical screen " +
167
+ "(you'd hear audio but see nothing). The server must run where it has a real " +
168
+ "display: start it from a terminal INSIDE your logged-in desktop session " +
169
+ "(`npx romdevtools`), then point your agent at that server. (A server spawned " +
170
+ "by your agent host, over plain SSH, or from a tty/headless box has no display. " +
171
+ "A virtual display like Xvfb works too — it reports as the real driver, not " +
172
+ "\"offscreen\".)",
173
+ ), "no-display");
174
+ }
151
175
  return _sdlModule;
152
176
  } catch (e) {
177
+ if (e?.sdlKind) throw e; // already-tagged (e.g. the offscreen check above)
153
178
  const isModuleErr = e?.code === "ERR_MODULE_NOT_FOUND" ||
154
179
  /sdl\.node|dist[\\/]/.test(e?.message || "");
155
180
  throw tag(new Error(e?.message ?? String(e)),