romdevtools 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/AGENTS.md +21 -14
  2. package/CHANGELOG.md +125 -1
  3. package/README.md +13 -8
  4. package/examples/atari2600/main.asm +1 -1
  5. package/examples/atari2600/templates/default.asm +1 -1
  6. package/examples/atari2600/templates/paddle.asm +59 -47
  7. package/examples/atari7800/main.c +1 -1
  8. package/examples/atari7800/templates/default.c +1 -1
  9. package/examples/atari7800/templates/music_demo.c +1 -1
  10. package/examples/c64/main.c +1 -1
  11. package/examples/c64/templates/platformer.c +2 -2
  12. package/examples/c64/templates/puzzle.c +1 -1
  13. package/examples/c64/templates/racing.c +3 -3
  14. package/examples/c64/templates/shmup.c +6 -5
  15. package/examples/c64/templates/sports.c +4 -4
  16. package/examples/gb/main.asm +1 -1
  17. package/examples/gb/main.c +1 -1
  18. package/examples/gb/templates/puzzle.c +1 -1
  19. package/examples/gb/templates/racing.c +1 -1
  20. package/examples/gb/templates/shmup.c +1 -1
  21. package/examples/gba/templates/gba_hello.c +1 -1
  22. package/examples/gba/templates/maxmod_demo.c +1 -1
  23. package/examples/gba/templates/puzzle.c +17 -3
  24. package/examples/gba/templates/racing.c +16 -2
  25. package/examples/gba/templates/shmup.c +23 -4
  26. package/examples/gba/templates/tonc_hello.c +6 -4
  27. package/examples/gbc/main.asm +1 -1
  28. package/examples/gbc/templates/puzzle.c +1 -1
  29. package/examples/gbc/templates/racing.c +1 -1
  30. package/examples/gbc/templates/shmup.c +1 -1
  31. package/examples/genesis/main.s +1 -1
  32. package/examples/genesis/templates/puzzle.c +1 -1
  33. package/examples/genesis/templates/racing.c +45 -1
  34. package/examples/genesis/templates/shmup.c +12 -3
  35. package/examples/genesis/templates/shmup_2p.c +2 -2
  36. package/examples/genesis/templates/sports.c +39 -0
  37. package/examples/gg/templates/hello_sprite.c +38 -23
  38. package/examples/gg/templates/music_demo.c +11 -8
  39. package/examples/gg/templates/platformer.c +37 -15
  40. package/examples/gg/templates/racing.c +25 -12
  41. package/examples/gg/templates/shmup.c +12 -6
  42. package/examples/gg/templates/sports.c +30 -16
  43. package/examples/gg/templates/tile_engine.c +24 -10
  44. package/examples/lynx/templates/platformer.c +7 -1
  45. package/examples/lynx/templates/puzzle.c +8 -2
  46. package/examples/lynx/templates/racing.c +7 -1
  47. package/examples/lynx/templates/sports.c +7 -1
  48. package/examples/nes/main.c +2 -2
  49. package/examples/nes/space-shooter/nes_runtime.h +1 -1
  50. package/examples/nes/templates/default.c +4 -1
  51. package/examples/nes/templates/racing.c +50 -1
  52. package/examples/pce/main.c +1 -1
  53. package/examples/sms/templates/hello_sprite.c +1 -1
  54. package/examples/sms/templates/music_demo.c +1 -1
  55. package/examples/sms/templates/puzzle.c +1 -1
  56. package/examples/sms/templates/racing.c +1 -1
  57. package/examples/sms/templates/shmup.c +1 -1
  58. package/examples/sms/templates/shmup_2p.c +2 -2
  59. package/examples/snes/main.asm +1 -1
  60. package/examples/snes/templates/c-hello-data.asm +309 -14
  61. package/examples/snes/templates/c-hello.c +13 -2
  62. package/examples/snes/templates/default.c +1 -1
  63. package/examples/snes/templates/hello_sprite-data.asm +300 -2
  64. package/examples/snes/templates/hello_sprite.c +10 -1
  65. package/examples/snes/templates/music_demo-data.asm +300 -2
  66. package/examples/snes/templates/music_demo.c +10 -1
  67. package/examples/snes/templates/platformer-data.asm +300 -2
  68. package/examples/snes/templates/platformer.c +10 -1
  69. package/examples/snes/templates/puzzle-data.asm +300 -2
  70. package/examples/snes/templates/puzzle.c +11 -1
  71. package/examples/snes/templates/racing-data.asm +300 -2
  72. package/examples/snes/templates/racing.c +40 -4
  73. package/examples/snes/templates/shmup-data.asm +299 -6
  74. package/examples/snes/templates/shmup.c +11 -7
  75. package/examples/snes/templates/sports-data.asm +300 -2
  76. package/examples/snes/templates/sports.c +40 -5
  77. package/package.json +1 -1
  78. package/src/cheats/lookup.js +39 -18
  79. package/src/http/routes.js +58 -33
  80. package/src/http/skill-doc.js +10 -9
  81. package/src/http/swagger.js +1 -1
  82. package/src/http/tool-registry.js +72 -5
  83. package/src/mcp/server.js +6 -5
  84. package/src/mcp/state.js +8 -6
  85. package/src/mcp/tool-manifest.js +7 -7
  86. package/src/mcp/tools/cheats.js +4 -3
  87. package/src/mcp/tools/index.js +18 -2
  88. package/src/mcp/tools/playtest.js +48 -35
  89. package/src/mcp/tools/project.js +39 -73
  90. package/src/mcp/tools/rom-id.js +49 -4
  91. package/src/mcp/tools/tile-inspect.js +1 -1
  92. package/src/mcp/tools/toolchain.js +183 -19
  93. package/src/mcp/tools/trace-vram-source.js +3 -3
  94. package/src/mcp/tools/watch-memory.js +27 -46
  95. package/src/observer/livestream.html +41 -5
  96. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +5 -5
  97. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  98. package/src/platforms/gb/TROUBLESHOOTING.md +1 -1
  99. package/src/platforms/gb/UPSTREAM_SOURCES.md +1 -1
  100. package/src/platforms/gb/lib/c/README.md +2 -2
  101. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +1 -1
  102. package/src/platforms/gbc/MENTAL_MODEL.md +3 -3
  103. package/src/platforms/gbc/TROUBLESHOOTING.md +5 -5
  104. package/src/platforms/gbc/UPSTREAM_SOURCES.md +2 -2
  105. package/src/platforms/gbc/lib/c/README.md +2 -2
  106. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +1 -1
  107. package/src/platforms/gg/MENTAL_MODEL.md +14 -13
  108. package/src/platforms/gg/lib/c/vdp_init.c +10 -8
  109. package/src/platforms/msx/MENTAL_MODEL.md +1 -1
  110. package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
  111. package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
  112. package/src/platforms/pce/MENTAL_MODEL.md +1 -1
  113. package/src/platforms/pce/lib/c/pce_hw.h +1 -0
  114. package/src/platforms/pce/lib/c/pce_video.c +26 -0
  115. package/src/platforms/sms/MENTAL_MODEL.md +12 -12
  116. package/src/platforms/sms/lib/c/vdp_init.c +10 -8
  117. package/src/platforms/sms/lib/vdp_init.s +1 -1
  118. package/src/playtest/playtest.js +25 -0
  119. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
  120. package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
  121. package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
  122. package/src/toolchains/genesis-c/README.md +1 -1
  123. package/src/toolchains/sdcc/preflight-lint.js +47 -7
  124. package/src/toolchains/snes-c/snes-c.js +3 -7
@@ -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
 
@@ -73,19 +73,20 @@ check the live OAM Y bytes for $D0 in a slot before them. That's
73
73
  still the diagnosis; the runtime just doesn't create the problem
74
74
  on its own anymore.
75
75
 
76
- ### R6 sprite-tile-base default: $0000, NOT $2000
77
-
78
- `gg_vdp_init()` sets R6 = 0xFB. R6 bit 2 is the SA13 select for
79
- sprite tile data — and bit 2 is **CLEAR** in 0xFB. That means
80
- sprite tiles read from `$0000-$1FFF`, **sharing the bank with BG
81
- tiles**. Many references (including the older comments we just
82
- fixed in `vdp_init.c` and `load_tiles.c`) say "R6=0xFB → sprite
83
- tiles at $2000" that's wrong.
84
-
85
- If you want sprite tiles in their own bank at $2000, set
86
- `vdp_write_reg(6, 0xFF)` AND upload tiles to VRAM $2000. Otherwise
87
- upload sprite tiles to $0000 alongside BG tiles (just make sure
88
- they don't collide).
76
+ ### R6 sprite-tile-base: default is $2000 (0xFF)
77
+
78
+ `gg_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
79
+ sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
80
+ from `$2000-$3FFF`, in their **own bank** separate from BG tiles at
81
+ $0000. This is the baseline because every bundled scaffold uploads
82
+ its sprite tiles to `$2000` (`gg_load_tiles(0x2000, …)`) the
83
+ default and the scaffolds match, so sprites Just Show Up.
84
+
85
+ Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
86
+ (sharing the BG bank). If you ever set R6=0xFB you MUST also upload
87
+ your sprite tiles to $0000, or the VDP reads the empty/BG bank and
88
+ every sprite is invisible — the classic GG/SMS "my sprites don't
89
+ show up" trap.
89
90
 
90
91
  The `sprites({op:'inspect'})` tool's `spriteTileDataBase` field reports the
91
92
  address the VDP is actually reading from — trust that over any
@@ -3,15 +3,17 @@
3
3
  * Writes the 11 mode-4 registers to a sane baseline:
4
4
  * display OFF, vblank IRQ off, 192-line mode 4, name table at $3800,
5
5
  * BG tile data at $0000, sprite attr table at $3F00, sprite tile data
6
- * at $0000 (R6=0xFB → SA13 clear → tiles read from $0000-$1FFF). Call
6
+ * at $2000 (R6=0xFF → SA13 set → tiles read from $2000-$3FFF). Call
7
7
  * once after reset before uploading palette/tiles/map.
8
8
  *
9
- * Footgun: many SMS/GG references say "R6=0xFB sprite tiles at $2000"
10
- * which is BACKWARDS. R6 bit 2 (the SA13 select) is CLEAR in 0xFB, so
11
- * sprite tiles read from $0000 (sharing the bank with BG tiles). To
12
- * separate sprite tiles to $2000, set R6 = 0xFF instead. The
13
- * sprites({op:'inspect'}) tool's spriteTileDataBase field will show you the
14
- * real address the VDP is reading from.
9
+ * Sprite-tile base: R6 bit 2 (SA13) selects $0000 (clear) vs $2000 (set).
10
+ * We default to R6=0xFF ($2000) because EVERY bundled scaffold uploads its
11
+ * sprite tiles to $2000 (gg_load_tiles(0x2000, ...)) so the baseline must
12
+ * match what consumers do, or sprites read from the empty/BG bank and render
13
+ * invisible. (Many SMS/GG references say "R6=0xFB $2000", which is backwards:
14
+ * 0xFB has SA13 CLEAR = $0000.) If you instead keep sprite tiles in the BG
15
+ * bank at $0000, set R6=0xFB. sprites({op:'inspect'}) → spriteTileDataBase
16
+ * shows the address the VDP is actually reading from.
15
17
  *
16
18
  * After loading assets, enable display by re-writing R1 with bit 6 set:
17
19
  * gg_vdp_display_on();
@@ -33,7 +35,7 @@ void gg_vdp_init(void) {
33
35
  0xFF, /* R3: color table (ignored in M4) */
34
36
  0xFF, /* R4: BG tile data at $0000 */
35
37
  0xFF, /* R5: sprite attr table at $3F00 */
36
- 0xFB, /* R6: sprite tile data at $0000 (set 0xFF for $2000) */
38
+ 0xFF, /* R6: sprite tile data at $2000 (SA13 set; scaffolds upload here) */
37
39
  0x00, /* R7: border = sprite palette entry 0 */
38
40
  0x00, /* R8: BG X scroll */
39
41
  0x00, /* R9: BG Y scroll */
@@ -106,7 +106,7 @@ exactly this.
106
106
  - `palette({source:'live'})` — V9938 9-bit GRB (or TMS9918 fixed) 16 entries.
107
107
  - `sprites({op:'inspect'})` — VRAM sprite-attribute table, up to 32 sprites.
108
108
  - `symbols({op:'map', map})` — pass the sdld `.map` (the `symbols` field from
109
- buildSourceWithDebug) to see where SDCC placed your variables/code, grouped by
109
+ build({output:'romWithDebug'})) to see where SDCC placed your variables/code, grouped by
110
110
  region (bios / cart_rom / work_ram).
111
111
  - `audioDebug({op:'inspect', chip: "ay8910"})` — the AY-3-8910 PSG: 3 square-wave
112
112
  channels (tone period→Hz, amplitude, tone/noise enable) + a shared noise
@@ -140,7 +140,7 @@ Overflow it and there's no error — your globals quietly collide with
140
140
  the stack or shadow OAM → corrupted state, sprites that flicker to
141
141
  garbage, random crashes.
142
142
 
143
- **Check the `ramUsage` field in the buildSource/runSource response** —
143
+ **Check the `ramUsage` field in the build response** —
144
144
  it lists your BSS / DATA / ZEROPAGE segment sizes from the linker map.
145
145
  If BSS+DATA is approaching the config's RAM region, shrink your state:
146
146
  prefer `uint8_t` over `int`, bit-pack flags, use small fixed arrays,
@@ -56,6 +56,7 @@ volatile uint8_t nmi_counter = 0;
56
56
  * (so OAM segment placement at $0200 is linker-enforced). oam_index
57
57
  * tracks the next free slot for oam_spr(). */
58
58
  static uint8_t oam_index = 0;
59
+ static void oam_hide_unused(void); /* fwd decl — used by ppu_wait_nmi (NES-1) */
59
60
 
60
61
  /* ── VRAM write queue ─────────────────────────────────────────────
61
62
  * Each entry is { hi, lo, byte }. NMI walks the queue, writes
@@ -130,7 +131,12 @@ void ppu_wait_vblank(void) {
130
131
  }
131
132
 
132
133
  void ppu_wait_nmi(void) {
133
- uint8_t target = (uint8_t)(nmi_counter + 1);
134
+ uint8_t target;
135
+ /* Hide last frame's now-unused sprite slots BEFORE waiting, so the buffer
136
+ * the NMI's OAM-DMA copies is fully staged (live slots written by oam_spr,
137
+ * stale slots parked off-screen) — never a half-cleared buffer (NES-1). */
138
+ oam_hide_unused();
139
+ target = (uint8_t)(nmi_counter + 1);
134
140
  while (nmi_counter != target) { /* spin */ }
135
141
  }
136
142
 
@@ -159,15 +165,31 @@ void palette_load(const uint8_t *pal32) {
159
165
 
160
166
  /* ── OAM ──────────────────────────────────────────────────────── */
161
167
 
168
+ /* High-water mark: the largest oam_index reached last frame. Lets us blank
169
+ * ONLY the slots a frame stopped using, instead of the whole 256-byte buffer
170
+ * every frame. */
171
+ static uint8_t oam_high = 0;
172
+
162
173
  void oam_clear(void) {
174
+ /* NES-1 FIX: do NOT blank the whole shadow buffer here. The old full clear
175
+ * wrote slot 0's Y=$FF FIRST and took ~hundreds of cycles; if the NMI's
176
+ * OAM-DMA fired mid-clear it copied a HALF-CLEARED buffer → the live sprite
177
+ * vanished every other frame (the classic "sprite flickers to black"
178
+ * sprite-light scaffold bug). Instead we just reset the staging index here;
179
+ * ppu_wait_nmi() hides the slots this frame stopped using, so the DMA only
180
+ * ever sees a fully-staged buffer. */
181
+ oam_index = 0;
182
+ }
183
+
184
+ /* Hide slots [oam_index .. oam_high] (the ones used last frame but not this
185
+ * frame) by parking their Y off-screen. Called from ppu_wait_nmi AFTER the
186
+ * game has staged its live sprites, so live slots are never blanked. */
187
+ static void oam_hide_unused(void) {
163
188
  uint16_t i;
164
- for (i = 0; i < 256; i += 4) {
189
+ for (i = oam_index; i < (uint16_t)oam_high + 4 && i < 256; i += 4) {
165
190
  shadow_oam[i] = 0xFF; /* Y off-screen */
166
- shadow_oam[i + 1] = 0; /* tile */
167
- shadow_oam[i + 2] = 0; /* attr */
168
- shadow_oam[i + 3] = 0; /* X */
169
191
  }
170
- oam_index = 0;
192
+ oam_high = oam_index;
171
193
  }
172
194
 
173
195
  void oam_spr(uint8_t x, uint8_t y, uint8_t tile, uint8_t attr) {
@@ -90,7 +90,7 @@ screen. Keep at least one (2+ byte) global. See TROUBLESHOOTING.md.
90
90
  - `background({view:'renderState'})` — VDC R5 screen-enable, BG scroll, SATB source.
91
91
  - `palette({source:'live'})` — VCE 512-entry 9-bit GRB (area:'bg'|'sprite').
92
92
  - `sprites({op:'inspect'})` — SATB 64 sprites (x/y/tile/palette/size/flip).
93
- - `symbols({op:'map'})` — where cc65 placed your variables (after buildSourceWithDebug).
93
+ - `symbols({op:'map'})` — where cc65 placed your variables (after build({output:'romWithDebug'})).
94
94
  - `audioDebug({op:'inspect', chip: "pce"})` — the HuC6280 PSG: 6 wavetable channels
95
95
  (per-channel freq/volume/wave; channels 4-5 can also do noise) + main amplitude
96
96
  + LFO.
@@ -96,6 +96,7 @@ void vdc_set_reg(u8 reg, u16 val); /* select reg, write 16-bit valu
96
96
  void vram_set_write_addr(u16 addr); /* point MAWR + arm VWR streaming */
97
97
  void vram_write(u16 addr, const u16 *data, u16 n); /* upload n words to VRAM[addr] */
98
98
  void vce_set_color(u16 idx, u16 grb); /* set VCE palette entry (0..511) */
99
+ void vdc_init(void); /* program VDC display timing (256x224 NTSC); auto-run by *_enable */
99
100
  void bg_enable(void); /* VDC R5: background on + VBlank IRQ (so waitvsync works) */
100
101
  void spr_enable(void); /* VDC R5: sprites on + VBlank IRQ */
101
102
  void disp_enable(void); /* VDC R5: BG + SPR + VBlank IRQ on at once */
@@ -70,17 +70,43 @@ void vblank_irq_enable(void) {
70
70
  vdc_set_reg(VDC_CR, _pce_cr);
71
71
  }
72
72
 
73
+ /* Program the VDC display-timing registers for a standard NTSC 256x224 (H32)
74
+ * screen. WITHOUT this the geargrafx core falls back to power-on register
75
+ * defaults that composite the 32-row BAT into the display DOUBLED (the scene
76
+ * drawn twice, top + bottom halves, with a black right margin) — the PCE-1
77
+ * "doubled picture" bug. Values match cc65's pce.lib / standard PCE homebrew:
78
+ * MWR R9 = 32x32 virtual screen, 256px-wide BAT
79
+ * HSR R10 / HDR R11 = 256px (32 char) horizontal display
80
+ * VPR R12 / VDW R13 / VCR R14 = 224-line vertical window
81
+ * Called automatically the first time the display is enabled (idempotent). */
82
+ static u8 _pce_vdc_inited = 0;
83
+ void vdc_init(void) {
84
+ if (_pce_vdc_inited) return;
85
+ _pce_vdc_inited = 1;
86
+ vdc_set_reg(VDC_MWR, 0x0010); /* 32x32 virtual map, 256px BAT */
87
+ vdc_set_reg(VDC_BXR, 0x0000); /* BG X scroll = 0 */
88
+ vdc_set_reg(VDC_BYR, 0x0000); /* BG Y scroll = 0 */
89
+ vdc_set_reg(VDC_HSR, 0x0202); /* horizontal sync width/start */
90
+ vdc_set_reg(VDC_HDR, 0x031F); /* horizontal display = 32 chars (256px) */
91
+ vdc_set_reg(VDC_VPR, 0x0F02); /* vertical sync */
92
+ vdc_set_reg(VDC_VDW, 0x00DF); /* vertical display = 224 lines */
93
+ vdc_set_reg(VDC_VCR, 0x00EE); /* vertical display end */
94
+ }
95
+
73
96
  void bg_enable(void) {
97
+ vdc_init();
74
98
  _pce_cr |= (VDC_CR_BG_ON | VDC_CR_VBLANK_IRQ);
75
99
  vdc_set_reg(VDC_CR, _pce_cr);
76
100
  }
77
101
 
78
102
  void spr_enable(void) {
103
+ vdc_init();
79
104
  _pce_cr |= (VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
80
105
  vdc_set_reg(VDC_CR, _pce_cr);
81
106
  }
82
107
 
83
108
  void disp_enable(void) {
109
+ vdc_init();
84
110
  _pce_cr |= (VDC_CR_BG_ON | VDC_CR_SPR_ON | VDC_CR_VBLANK_IRQ);
85
111
  vdc_set_reg(VDC_CR, _pce_cr);
86
112
  }
@@ -78,7 +78,7 @@ R1 = 0x80 display OFF, vblank IRQ off, 192-line
78
78
  R2 = 0xFF name table at $3800
79
79
  R4 = 0xFF BG tile data at $0000
80
80
  R5 = 0xFF sprite attr table at $3F00
81
- R6 = 0xFB sprite tile data at $0000 (NOT $2000 see footgun below)
81
+ R6 = 0xFF sprite tile data at $2000 (own bank; scaffolds upload here)
82
82
  R7 = 0x00 border colour
83
83
  ```
84
84
 
@@ -142,19 +142,19 @@ buffer in WRAM and uploads it to the SAT each vblank.
142
142
  `sprites({op:'inspect'})` shows the live OAM bytes + reports
143
143
  `spriteTileDataBase` — trust it over comments when sprites misbehave.
144
144
 
145
- ### R6 sprite-tile-base default: $0000, NOT $2000
145
+ ### R6 sprite-tile-base: default is $2000 (0xFF)
146
146
 
147
- `sms_vdp_init()` sets R6 = 0xFB. R6 bit 2 is the SA13 select for
148
- sprite tile data — and bit 2 is **CLEAR** in 0xFB. That means
149
- sprite tiles read from `$0000-$1FFF`, **sharing the bank with BG
150
- tiles**. Many references (including older comments in our own
151
- `vdp_init.c` and `load_tiles.c`, since fixed) say "R6=0xFB → sprite
152
- tiles at $2000" that's wrong.
147
+ `sms_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
148
+ sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
149
+ from `$2000-$3FFF`, their **own bank** separate from BG tiles at
150
+ $0000. This matches every bundled scaffold, which uploads sprite
151
+ tiles to `$2000` (`sms_load_tiles(0x2000, )`) default and
152
+ scaffolds agree, so sprites render.
153
153
 
154
- If you want sprite tiles in their own bank at $2000, set
155
- `vdp_write_reg(6, 0xFF)` AND upload tiles to VRAM $2000. Otherwise
156
- upload sprite tiles to $0000 alongside BG tiles (just make sure
157
- they don't collide).
154
+ Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
155
+ (shared with the BG bank). If you set R6=0xFB you MUST upload your
156
+ sprite tiles to $0000 too, or the VDP reads the empty/BG bank and
157
+ every sprite is invisible.
158
158
 
159
159
  ## Palette (CRAM)
160
160
 
@@ -3,15 +3,17 @@
3
3
  * Writes the 11 mode-4 registers to a sane baseline:
4
4
  * display OFF, vblank IRQ off, 192-line mode 4, name table at $3800,
5
5
  * BG tile data at $0000, sprite attr table at $3F00, sprite tile data
6
- * at $0000 (R6=0xFB → SA13 clear → tiles read from $0000-$1FFF). Call
6
+ * at $2000 (R6=0xFF → SA13 set → tiles read from $2000-$3FFF). Call
7
7
  * once after reset before uploading palette/tiles/map.
8
8
  *
9
- * Footgun: many SMS references say "R6=0xFB sprite tiles at $2000"
10
- * which is BACKWARDS. R6 bit 2 (the SA13 select) is CLEAR in 0xFB, so
11
- * sprite tiles read from $0000 (sharing the bank with BG tiles). To
12
- * separate sprite tiles to $2000, set R6 = 0xFF instead. The
13
- * sprites({op:'inspect'}) tool's spriteTileDataBase field will show you the
14
- * real address the VDP is reading from.
9
+ * Sprite-tile base: R6 bit 2 (SA13) selects $0000 (clear) vs $2000 (set).
10
+ * We default to R6=0xFF ($2000) because EVERY bundled scaffold uploads its
11
+ * sprite tiles to $2000 (sms_load_tiles(0x2000, ...)) so the baseline must
12
+ * match what consumers do, or sprites read from the empty/BG bank and render
13
+ * invisible. (Many SMS references say "R6=0xFB $2000", which is backwards:
14
+ * 0xFB has SA13 CLEAR = $0000.) If you instead keep sprite tiles in the BG
15
+ * bank at $0000, set R6=0xFB. sprites({op:'inspect'}) → spriteTileDataBase
16
+ * shows the address the VDP is actually reading from.
15
17
  *
16
18
  * After loading assets, enable display by re-writing R1 with bit 6 set:
17
19
  * sms_vdp_display_on();
@@ -33,7 +35,7 @@ void sms_vdp_init(void) {
33
35
  0xFF, /* R3: color table (ignored in M4) */
34
36
  0xFF, /* R4: BG tile data at $0000 */
35
37
  0xFF, /* R5: sprite attr table at $3F00 */
36
- 0xFB, /* R6: sprite tile data at $0000 (set 0xFF for $2000) */
38
+ 0xFF, /* R6: sprite tile data at $2000 (SA13 set; scaffolds upload here) */
37
39
  0x00, /* R7: border = sprite palette entry 0 */
38
40
  0x00, /* R8: BG X scroll */
39
41
  0x00, /* R9: BG Y scroll */
@@ -40,7 +40,7 @@ _vdp_init_regs:
40
40
  .db $FF ; R3: color table — M4 ignores
41
41
  .db $FF ; R4: BG tile data — M4: bit 2 selects $0000 vs $2000
42
42
  .db $FF ; R5: sprite attr table base ($3F00 = $7E << 7)
43
- .db $FB ; R6: sprite tile data bit 2 selects bank ($2000 here)
43
+ .db $FF ; R6: sprite tile data at $2000 (SA13 set; scaffolds upload here)
44
44
  .db $00 ; R7: border color = sprite palette entry 0
45
45
  .db $00 ; R8: BG X scroll
46
46
  .db $00 ; R9: BG Y scroll
@@ -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)),
@@ -12,7 +12,7 @@
12
12
  # runtime. There is no CHARS segment.
13
13
  #
14
14
  # To use this preset:
15
- # 1. linkerConfig: "chr-ram" on buildSource
15
+ # 1. linkerConfig: "chr-ram" on build({output:'rom'})
16
16
  # 2. Supply your own crt0/header source that writes the 16-byte iNES
17
17
  # header with byte 5 = 0. Easiest: paste the snippet
18
18
  # getStarterSnippet({platform:"nes", name:"chr_ram_header", language:"asm"}).
@@ -12,7 +12,7 @@
12
12
  # runtime. There is no CHARS segment.
13
13
  #
14
14
  # To use this preset:
15
- # 1. linkerConfig: "chr-ram" on buildSource
15
+ # 1. linkerConfig: "chr-ram" on build({output:'rom'})
16
16
  # 2. Supply your own crt0/header source that writes the 16-byte iNES
17
17
  # header with byte 5 = 0. Easiest: paste the snippet
18
18
  # getStarterSnippet({platform:"nes", name:"chr_ram_header", language:"asm"}).
@@ -91,7 +91,7 @@ _exit: jsr donelib
91
91
  ; rti
92
92
  ;
93
93
  ; ; then DELETE the `nmi: rti` line below from this crt0 (or load a
94
- ; ; modified crt0 via your own buildSource sources entry).
94
+ ; ; modified crt0 via your own build({output:'rom'}) sources entry).
95
95
 
96
96
  .segment "STARTUP"
97
97
 
@@ -23,7 +23,7 @@ once the WASM artifacts ship. Pipeline shape:
23
23
  - **WASM port of cc1/as/ld (stage 2)**: pending.
24
24
  - **SGDK native build against the cross-toolchain (stage 3)**: pending.
25
25
  - **JS driver `buildGenesisC()`**: pending.
26
- - **buildSource wiring**: pending.
26
+ - **build({output:'rom'}) wiring**: pending.
27
27
 
28
28
  ## Why a 3-stage build
29
29
 
@@ -68,13 +68,53 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
68
68
  // CONSERVATIVE: only flag when we can SEE the counter declared u8 and
69
69
  // the bound is a constant we can evaluate — never guess.
70
70
  {
71
- // Collect names declared as 8-bit ints anywhere in the file.
72
- const u8re = /\b(?:unsigned\s+char|char|u8|uint8_t|uint8|int8_t|int8|signed\s+char)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/g;
73
- const u8names = new Set();
74
- let dm;
75
- while ((dm = u8re.exec(source))) {
76
- for (const n of dm[1].split(",")) u8names.add(n.trim());
71
+ // Build a SCOPE-AWARE map of where each name is declared and at what
72
+ // width. A name like `i` is commonly re-declared in several functions
73
+ // some as uint8_t, some as uint16_t. A flat "is this name ever u8"
74
+ // set wrongly flags the uint16_t loop just because a DIFFERENT
75
+ // function declared its own `i` as uint8_t (the SMS/GG default
76
+ // scaffold false-positive). Instead we record EVERY declaration's
77
+ // line + width, then for each loop consult the nearest declaration of
78
+ // the counter that appears ABOVE the loop — i.e. the one actually in
79
+ // scope — and only flag it when that declaration is 8-bit.
80
+ //
81
+ // decls: Map<name, Array<{line:number, u8:boolean}>> (line is 1-based)
82
+ const decls = new Map();
83
+ const addDecl = (names, line, u8) => {
84
+ for (const raw of names.split(",")) {
85
+ const n = raw.trim();
86
+ if (!n) continue;
87
+ if (!decls.has(n)) decls.set(n, []);
88
+ decls.get(n).push({ line, u8 });
89
+ }
90
+ };
91
+ const u8re = /\b(?:unsigned\s+char|char|u8|uint8_t|uint8|int8_t|int8|signed\s+char)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/;
92
+ // 16-bit-or-wider integer declarations (and float/double, which also
93
+ // can't overflow at 255). Anything not 8-bit is "wide" for our purpose.
94
+ const wideRe = /\b(?:unsigned\s+(?:short|int|long)|signed\s+(?:short|int|long)|short|int|long|u16|u32|u64|uint16_t|uint16|int16_t|int16|uint32_t|uint32|int32_t|int32|uint64_t|uint64|int64_t|int64|size_t|ptrdiff_t)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*;/;
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const code = lines[i].replace(/\/\/.*$/, "").replace(/\/\*.*?\*\//g, "");
97
+ const m8 = code.match(u8re);
98
+ if (m8) { addDecl(m8[1], i + 1, true); continue; }
99
+ const mw = code.match(wideRe);
100
+ if (mw) addDecl(mw[1], i + 1, false);
77
101
  }
102
+ // Is the counter declared 8-bit in the scope visible at `loopLine`?
103
+ // Use the NEAREST declaration above the loop. If the nearest visible
104
+ // declaration is wide (uint16_t etc.), the loop is fine.
105
+ const counterIsU8AtLine = (counter, loopLine) => {
106
+ const ds = decls.get(counter);
107
+ if (!ds) return false;
108
+ let best = null;
109
+ for (const d of ds) {
110
+ if (d.line <= loopLine && (best === null || d.line > best.line)) best = d;
111
+ }
112
+ // No declaration above the loop (e.g. param/global declared after, or
113
+ // out-of-order) — fall back to "flag only if EVERY decl is u8" so we
114
+ // never wolf-cry on a name that is also declared wide somewhere.
115
+ if (best === null) return ds.every((d) => d.u8);
116
+ return best.u8;
117
+ };
78
118
  const evalConst = (expr) => {
79
119
  // Only literals and pure `A * B [* C]` products of decimal/hex ints.
80
120
  const t = expr.trim();
@@ -91,7 +131,7 @@ export function lintSdccSource(source, file = "main.c", opts = {}) {
91
131
  const m = code.match(/\bfor\s*\([^;]*;\s*([A-Za-z_]\w*)\s*<\s*([^;]+?)\s*;/);
92
132
  if (!m) continue;
93
133
  const [, counter, boundExpr] = m;
94
- if (!u8names.has(counter)) continue;
134
+ if (!counterIsU8AtLine(counter, i + 1)) continue;
95
135
  const bound = evalConst(boundExpr);
96
136
  if (bound !== null && bound > 255) {
97
137
  issues.push({
@@ -148,13 +148,9 @@ function normalizeSnesSources(args) {
148
148
  if (cFiles.length === 0) {
149
149
  throw new Error("buildSnesC: `sources` must include at least one .c file.");
150
150
  }
151
- if (cFiles.length > 1) {
152
- throw new Error(
153
- `buildSnesC: multiple .c files in sources (${cFiles.join(", ")}). ` +
154
- `Today only one .c file is supported per build — combine via #include or wait for ` +
155
- `multi-TU support. .asm/.s siblings work fine.`,
156
- );
157
- }
151
+ // Multiple .c files ARE supported: buildWithPvSnesLib compiles each to its
152
+ // own .obj (tcc→wla) and links them all (Stage 1 + Stage 3). The genre
153
+ // scaffolds ship main.c + snes_sfx.c and rely on this.
158
154
  return args.sources;
159
155
  }
160
156
  if (typeof args.source === "string") {