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.
- package/AGENTS.md +5 -5
- package/CHANGELOG.md +62 -1
- package/README.md +13 -8
- package/package.json +1 -1
- package/src/cheats/lookup.js +39 -18
- package/src/http/routes.js +58 -33
- package/src/http/skill-doc.js +10 -9
- package/src/http/swagger.js +1 -1
- package/src/http/tool-registry.js +72 -5
- package/src/mcp/server.js +6 -5
- package/src/mcp/state.js +8 -6
- package/src/mcp/tool-manifest.js +7 -7
- package/src/mcp/tools/cheats.js +4 -3
- package/src/mcp/tools/index.js +18 -2
- package/src/mcp/tools/playtest.js +48 -35
- package/src/mcp/tools/project.js +6 -51
- package/src/mcp/tools/rom-id.js +49 -4
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/trace-vram-source.js +3 -3
- package/src/mcp/tools/watch-memory.js +27 -46
- package/src/observer/livestream.html +7 -1
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +5 -5
- package/src/platforms/gb/MENTAL_MODEL.md +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +1 -1
- package/src/platforms/gb/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gb/lib/c/README.md +2 -2
- package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +1 -1
- package/src/platforms/gbc/MENTAL_MODEL.md +3 -3
- package/src/platforms/gbc/TROUBLESHOOTING.md +5 -5
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +2 -2
- package/src/platforms/gbc/lib/c/README.md +2 -2
- package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +1 -1
- package/src/playtest/playtest.js +25 -0
|
@@ -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 →
|
|
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
|
|
80
|
-
// `
|
|
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
|
-
// ──
|
|
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: "
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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**, `
|
|
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
|
-
- **`
|
|
202
|
-
and the ROM SOURCE it came from. The targeted version of `
|
|
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) | `
|
|
242
|
-
| Where did a VRAM graphic come from (Genesis) | `
|
|
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 `
|
|
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 `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
- `
|
|
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 `
|
|
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
|
-
`
|
|
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 `
|
|
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` — `
|
|
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 `
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
- `
|
|
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 `
|
|
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
|
|
package/src/playtest/playtest.js
CHANGED
|
@@ -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)),
|