romdevtools 0.28.0 → 0.29.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 (154) hide show
  1. package/AGENTS.md +51 -41
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +1 -1
  88. package/src/host/LibretroHost.js +59 -1
  89. package/src/http/tool-registry.js +11 -11
  90. package/src/mcp/tools/cheats.js +2 -1
  91. package/src/mcp/tools/frame.js +3 -2
  92. package/src/mcp/tools/index.js +3 -3
  93. package/src/mcp/tools/input.js +5 -4
  94. package/src/mcp/tools/lifecycle.js +6 -4
  95. package/src/mcp/tools/platform-docs.js +1 -1
  96. package/src/mcp/tools/preview-tile.js +6 -2
  97. package/src/mcp/tools/project.js +1098 -130
  98. package/src/mcp/tools/rom-id.js +5 -1
  99. package/src/mcp/tools/run-until.js +4 -2
  100. package/src/mcp/tools/snippets.js +6 -6
  101. package/src/mcp/tools/sprite-pipeline.js +14 -2
  102. package/src/mcp/tools/state.js +2 -1
  103. package/src/mcp/tools/tile-inspect.js +8 -1
  104. package/src/mcp/tools/toolchain.js +12 -1
  105. package/src/mcp/tools/watch-memory.js +4 -3
  106. package/src/observer/bus.js +73 -0
  107. package/src/observer/livestream.html +4 -2
  108. package/src/observer/tool-wrap.js +17 -14
  109. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  110. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  111. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  112. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  113. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  114. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  115. package/src/platforms/gb/lib/c/README.md +10 -11
  116. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  117. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  118. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  119. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  120. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  121. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  122. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  123. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  124. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  125. package/src/platforms/gbc/lib/c/README.md +10 -11
  126. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  127. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  128. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  129. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  130. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  131. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  132. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  133. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  134. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  135. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  136. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  137. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  138. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  139. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  140. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  141. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  142. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  143. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  144. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  145. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  146. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  147. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  148. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  149. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  150. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  151. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  152. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  153. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  154. package/src/toolchains/index.js +27 -11
@@ -20,3 +20,32 @@ uint8_t gg_joypad_read(void) {
20
20
  uint8_t start = ~PORT_GG_INPUT; /* GG-specific port bit 7 = START */
21
21
  return (uint8_t)((a & 0x3F) | (start & 0x80));
22
22
  }
23
+
24
+ /*
25
+ * Player 2 read — for ALTERNATING-TURNS or 2-controller play.
26
+ *
27
+ * HONEST NOTE: a real Game Gear has only ONE controller port on the unit; its
28
+ * 2P story is the Gear-to-Gear LINK CABLE (a second console). But the GG VDP
29
+ * and I/O chip are the SMS's, and gpgx wires the SMS's full split-across-
30
+ * $DC/$DD second-controller layout for GG too — so a SECOND PAD does drive
31
+ * port B in the emulator (and on an SMS-pad adapter), which is exactly what an
32
+ * alternating-turns 2P platformer needs (the two players never play at once).
33
+ *
34
+ * The hardware layout is the SMS's awkward split:
35
+ * PORT_JOY_A bits 6-7 = P2 UP, P2 DOWN
36
+ * PORT_JOY_B bits 0-3 = P2 LEFT, P2 RIGHT, P2 B1, P2 B2
37
+ * Reassembled into the same bit layout P1 uses:
38
+ * bit 0 = UP, 1 = DOWN, 2 = LEFT, 3 = RIGHT, 4 = B1, 5 = B2.
39
+ * Returns 0 when no P2 pad is present (all bits high = released after invert).
40
+ */
41
+ uint8_t gg_joypad_read_p2(void) {
42
+ uint8_t a = ~PORT_JOY_A; /* P2 UP in bit 6, DOWN in bit 7 */
43
+ uint8_t b = ~PORT_JOY_B; /* P2 LEFT bit 0, RIGHT 1, B1 2, B2 3 */
44
+ uint8_t up = (a >> 6) & 0x01; /* bit 6 -> bit 0 */
45
+ uint8_t down = (a >> 6) & 0x02; /* bit 7 -> bit 1 */
46
+ uint8_t left = (b << 2) & 0x04; /* bit 0 -> bit 2 */
47
+ uint8_t right = (b << 2) & 0x08; /* bit 1 -> bit 3 */
48
+ uint8_t b1 = (b << 2) & 0x10; /* bit 2 -> bit 4 */
49
+ uint8_t b2 = (b << 2) & 0x20; /* bit 3 -> bit 5 */
50
+ return (uint8_t)(up | down | left | right | b1 | b2);
51
+ }
@@ -96,7 +96,7 @@ void main(void) {
96
96
  `tgi_updatedisplay()` is the frame heartbeat — it ping-pongs the
97
97
  double-buffered display and waits for vblank.
98
98
 
99
- ## Drawing many rectangles in one frame (game scaffold pattern)
99
+ ## Drawing many rectangles in one frame (example-game pattern)
100
100
 
101
101
  The minimal example above draws "one rect per frame." For an actual
102
102
  game with HUD + background + sprites you'll do many tgi_bar / tgi_setcolor
@@ -44,7 +44,7 @@ Two things that trip agents up:
44
44
  Skipping it is the #1 "Lynx is blank" trap.
45
45
  - **Don't rely on `tgi_clear()`** to blank the screen in this
46
46
  toolchain/emulator path — use a full-screen `tgi_bar(0,0,maxx,maxy)`
47
- in the background colour instead. The bundled `shmup` scaffold uses
47
+ in the background colour instead. The bundled `shmup` example uses
48
48
  this exact loop; copy it.
49
49
 
50
50
  ## "tgi_outtextxy renders nothing"
@@ -54,7 +54,7 @@ cc65's default TGI on Lynx ships without a font. Either:
54
54
  live in `$cc65_share/target/lynx/fonts/`.
55
55
  2. Draw your own glyphs with `tgi_bar`/`tgi_line`.
56
56
 
57
- The bundled scaffolds work around this by using simple rectangles
57
+ The bundled example games work around this by using simple rectangles
58
58
  for game content + only short text strings. For game UI text, embed
59
59
  a bitmap font directly in your code.
60
60
 
@@ -93,7 +93,7 @@ cc65 is C89. No mixed declarations + code, no inline `for (uint8_t i
93
93
  = 0; ...)`, no compound literals, no // comments in some configs.
94
94
  Declare all variables at the top of each block.
95
95
 
96
- The bundled Lynx scaffolds are C89-clean — copy that pattern.
96
+ The bundled Lynx example games are C89-clean — copy that pattern.
97
97
 
98
98
  ## "Compile fails: no rule to make target lynx-bll.cfg"
99
99
 
@@ -12,13 +12,13 @@ romdev ships a **hardware helper library** (`src/platforms/msx/lib/c/`:
12
12
  `msx_psg_tone()` in plain C. It uses DIRECT Z80 I/O ports (the reliable path —
13
13
  NOT fragile inline-asm BIOS wrappers).
14
14
 
15
- The fastest way to a working game: **`scaffold({op:'game', platform: "msx", genre:
16
- "shmup"})`** — or any of `platformer` / `puzzle` / `sports` / `racing`, the full
17
- genre set. For a smaller starting point use **`scaffold({op:'project', platform:
18
- "msx", template: "sprite_move"})`** (also `music_sfx`, `catch_game`). Either drops
15
+ The fastest way to a working game: **fork the example game whose core loop is
16
+ nearest yours — `examples({op:'fork', example:"msx/shmup", name, path})`** — or any
17
+ of `platformer` / `puzzle` / `sports` / `racing`, the full genre set. For a smaller
18
+ starting point fork `msx/sprite_move` (also `music_sfx`, `catch_game`). Either drops
19
19
  a complete, *building* project — a verified playable example + the helper lib +
20
20
  the cart crt0 + docs. Read the example's `main.c`, then change it. Examples live in
21
- `examples/msx/`. The `platformer` scaffold column-streams the SCREEN 2 name table
21
+ `examples/msx/`. The `platformer` example column-streams the SCREEN 2 name table
22
22
  for a tile-by-tile side-scroll. **Gotcha:** read joystick **port 1**
23
23
  (`msx_read_joystick(1)`) — port 0 is the keyboard, which an emulator's gamepad
24
24
  doesn't drive.
@@ -85,5 +85,5 @@ ticker) already do this; copy the pattern for any direct PSG access you write.
85
85
  The old `msx_crt0.s` placed the SDCC `_INITIALIZER` area in RAM, so the boot
86
86
  copy duplicated uninitialised RAM onto itself: every value-initialised static
87
87
  read 0 and BSS was never zeroed. The bundled crt0 has been fixed (ROM-placed
88
- `_INITIALIZER` + a BSS-zero loop). If a project scaffolded before 2026-06-09
89
- shows ghost zeros, refresh its `msx_crt0.s` from a new scaffold.
88
+ `_INITIALIZER` + a BSS-zero loop). If a project forked before 2026-06-09
89
+ shows ghost zeros, refresh its `msx_crt0.s` from a freshly forked example.
@@ -87,6 +87,7 @@ void msx_clear_sprites(void);
87
87
  void msx_vblank_wait(void);
88
88
  uint8_t msx_read_joystick(uint8_t stick);
89
89
  void msx_psg_tone(uint8_t chan, uint16_t period, uint8_t vol);
90
+ void msx_psg_noise(uint8_t chan, uint8_t rate, uint8_t vol); /* vol 0 = off */
90
91
  void msx_music(uint8_t on); /* background melody on channel C — ON by default; 0 = off */
91
92
  void msx_music_tick(void); /* call once per frame (scaffolds do) */
92
93
  void msx_psg_off(uint8_t chan);
@@ -146,6 +146,31 @@ void msx_psg_tone(uint8_t chan, uint16_t period, uint8_t vol) {
146
146
  __asm__("ei");
147
147
  }
148
148
 
149
+ /* Play NOISE on PSG channel 0/1/2: the AY's one shared noise generator
150
+ * (reg 6, 5-bit period — bigger = lower rumble) routed into this channel by
151
+ * clearing its noise-disable mixer bit (reg 7 bits 3-5) while setting its
152
+ * tone-disable bit. The classic explosion/impact voice.
153
+ * msx_psg_noise(chan, rate, 0) silences the channel and re-masks its noise
154
+ * bit (msx_psg_off only re-masks TONE — it doesn't know about noise). */
155
+ void msx_psg_noise(uint8_t chan, uint8_t rate, uint8_t vol) {
156
+ uint8_t mixer;
157
+ __asm__("di"); /* same KEYINT race as above */
158
+ if (vol) {
159
+ PSGADDR = 6; /* noise period (shared) */
160
+ PSGWRITE = (uint8_t)(rate & 0x1F);
161
+ }
162
+ PSGADDR = (uint8_t)(8 + chan);
163
+ PSGWRITE = (uint8_t)(vol & 0x0F);
164
+ PSGADDR = 7;
165
+ mixer = PSGREAD;
166
+ mixer |= (uint8_t)(1 << chan); /* tone OFF for this channel */
167
+ if (vol) mixer &= (uint8_t)~(1 << (3 + chan)); /* noise ON */
168
+ else mixer |= (uint8_t)(1 << (3 + chan)); /* noise OFF */
169
+ PSGADDR = 7;
170
+ PSGWRITE = mixer;
171
+ __asm__("ei");
172
+ }
173
+
149
174
  /* Silence a PSG channel: zero its volume and re-disable its tone bit. */
150
175
  void msx_psg_off(uint8_t chan) {
151
176
  uint8_t mixer;
@@ -207,9 +207,9 @@ names also resolve (east→A, west→B). So `input({op:'set', a: true})` presses
207
207
  expected — unlike the genesis_plus_gx platforms (Genesis/SMS/GG), there's no
208
208
  surprise here.
209
209
 
210
- ## What `scaffold({op:'project'})` copies into your project
210
+ ## What `examples({op:'fork'})` copies into your project
211
211
 
212
- `scaffold({op:'project', platform:"nes", template:"hello_sprite"|"tile_engine"|"default"})`
212
+ `examples({op:'fork', example:"nes/hello_sprite"|"nes/tile_engine"|"nes/default", name, path})`
213
213
  writes these files into your project directory. **They're yours** — every
214
214
  byte that compiles is in the repo. Edit, fork, replace; nothing is auto-injected
215
215
  at build time.
@@ -59,44 +59,60 @@ static uint8_t oam_index = 0;
59
59
  static void oam_hide_unused(void); /* fwd decl — used by ppu_wait_nmi (NES-1) */
60
60
 
61
61
  /* ── VRAM write queue ─────────────────────────────────────────────
62
- * Each entry is { hi, lo, byte }. NMI walks the queue, writes
63
- * PPUADDR(hi); PPUADDR(lo); PPUDATA(byte) for each, then clears the
64
- * length. Length is capped at QUEUE_MAX entries; if game code overflows
65
- * it, we busy-wait for an NMI to flush and continue. */
66
- #define QUEUE_MAX 24
67
- static struct {
68
- uint8_t addr_hi;
69
- uint8_t addr_lo;
70
- uint8_t value;
71
- } vram_queue[QUEUE_MAX];
72
- static uint8_t vram_queue_len = 0;
73
-
74
- /* Called from the NMI handler in chr-ram.crt0.s. PPU is unlocked
75
- * (we're in vblank), so writes to $2006/$2007 are safe. */
76
- void __fastcall__ vram_queue_flush(void) {
77
- uint8_t i;
78
- if (vram_queue_len == 0) return;
79
- /* Reset the address latch by reading $2002 — even though the NMI
80
- * trampoline already did this when it ran the scroll-reset
81
- * sequence, that came AFTER we ran. Cheap insurance. */
82
- (void)PPUSTATUS;
83
- for (i = 0; i < vram_queue_len; i++) {
84
- PPUADDR = vram_queue[i].addr_hi;
85
- PPUADDR = vram_queue[i].addr_lo;
86
- PPUDATA = vram_queue[i].value;
87
- }
88
- vram_queue_len = 0;
89
- }
90
-
91
- /* Queue one byte. If full, wait for NMI to drain then enqueue. */
62
+ * A ring buffer of { hi, lo, byte } entries. The NMI drains it with
63
+ * PPUADDR(hi); PPUADDR(lo); PPUDATA(byte) per entry but only up to
64
+ * FLUSH_BUDGET entries per vblank (see the idiom note below). Game code
65
+ * that outruns the drain just blocks in vram_queue_push until a slot
66
+ * frees up; a big batch appears over 2-3 frames, invisible to a human.
67
+ *
68
+ * ── HARDWARE IDIOM (load-bearing) — the VBLANK BUDGET ──
69
+ * Vblank is ~2273 CPU cycles and OAM DMA already spends 513 of them.
70
+ * A flush that keeps writing past the end of vblank writes PPUDATA
71
+ * while RENDERING IS ACTIVE — the PPU's internal address register is
72
+ * busy fetching tiles, so those writes land at corrupted addresses.
73
+ * Symptom: a long batch of queued tiles where MOST land correctly but
74
+ * the tail is shifted or missing, identically every run. The budget
75
+ * caps the per-vblank drain so the flush always finishes inside vblank.
76
+ * The drain itself lives in the crt0's NMI handler IN ASSEMBLY (~40
77
+ * cycles/entry); compiled C spends 200+ cycles per entry, which blows
78
+ * the budget even for small batches — measured, not theoretical.
79
+ *
80
+ * ── HARDWARE IDIOM (load-bearing) the NMI/main-thread race ──
81
+ * The NMI fires asynchronously; if it drained the queue WHILE
82
+ * vram_queue_push was mid-update, the in-flight entry would be lost
83
+ * and a stale slot replayed. The lock byte makes the flush skip any
84
+ * vblank that catches a push in progress (the queue drains a frame
85
+ * later). Symptom without it: HUD text with characters missing or
86
+ * shifted, coming and going with timing. */
87
+ #define QUEUE_MAX 32 /* power of two — indices wrap via & */
88
+ #define QUEUE_MASK (QUEUE_MAX - 1)
89
+ #define FLUSH_BUDGET 16 /* keep in sync with the crt0 asm */
90
+ /* NOT static — the crt0's NMI drains the ring in assembly (see the
91
+ * vblank-budget idiom above; symbol names are part of the crt0 contract). */
92
+ uint8_t vram_q_hi[QUEUE_MAX];
93
+ uint8_t vram_q_lo[QUEUE_MAX];
94
+ uint8_t vram_q_val[QUEUE_MAX];
95
+ uint8_t vram_queue_head = 0;
96
+ volatile uint8_t vram_queue_len = 0;
97
+ volatile uint8_t vram_queue_lock = 0;
98
+
99
+ /* Queue one byte. If full, wait for the NMI to drain a slot (lock
100
+ * RELEASED while waiting — holding it would deadlock), then enqueue
101
+ * under the lock. */
92
102
  static void vram_queue_push(uint16_t ppu_addr, uint8_t v) {
93
- while (vram_queue_len >= QUEUE_MAX) {
103
+ uint8_t slot;
104
+ for (;;) {
105
+ vram_queue_lock = 1;
106
+ if (vram_queue_len < QUEUE_MAX) break;
107
+ vram_queue_lock = 0;
94
108
  ppu_wait_nmi();
95
109
  }
96
- vram_queue[vram_queue_len].addr_hi = (uint8_t)(ppu_addr >> 8);
97
- vram_queue[vram_queue_len].addr_lo = (uint8_t)(ppu_addr & 0xFF);
98
- vram_queue[vram_queue_len].value = v;
110
+ slot = (uint8_t)((vram_queue_head + vram_queue_len) & QUEUE_MASK);
111
+ vram_q_hi[slot] = (uint8_t)(ppu_addr >> 8);
112
+ vram_q_lo[slot] = (uint8_t)(ppu_addr & 0xFF);
113
+ vram_q_val[slot] = v;
99
114
  ++vram_queue_len;
115
+ vram_queue_lock = 0;
100
116
  }
101
117
 
102
118
  /* ── PPU control ──────────────────────────────────────────────── */
@@ -408,3 +424,102 @@ void sound_play_noise(uint8_t period_4bit, uint8_t vol_4bit, uint8_t length_fram
408
424
  void sound_off(void) {
409
425
  APUSTATUS = 0x00;
410
426
  }
427
+
428
+
429
+ /* ════════════════════════════════════════════════════════════════════
430
+ * Text + font (0.29.0 examples contract)
431
+ * ════════════════════════════════════════════════════════════════════ */
432
+
433
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
434
+ * Font glyphs are 1bpp (plane 0 only → colour index 1 of the BG palette).
435
+ * They upload into the BACKGROUND pattern table at $1400+ — tile ids $40+ —
436
+ * NOT the sprite table at $0000 (the runtime maps BG to $1000, sprites to
437
+ * $0000 via PPUCTRL). Requires: PPU rendering OFF during font_upload (raw
438
+ * $2007 writes), 37*16 = 592 bytes of CHR-RAM free at $1400-$164F. */
439
+ static const uint8_t font8[37][8] = {
440
+ /* 0-9 */
441
+ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
442
+ {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00}, {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
443
+ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
444
+ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
445
+ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
446
+ /* A-Z */
447
+ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
448
+ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
449
+ {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00}, {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
450
+ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3E,0x00}, {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
451
+ {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00}, {0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x00},
452
+ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
453
+ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
454
+ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
455
+ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
456
+ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
457
+ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
458
+ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
459
+ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
460
+ /* '-' */
461
+ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
462
+ };
463
+ #define FONT_BASE_TILE 0x40
464
+
465
+ void font_upload(void) {
466
+ uint8_t g, r;
467
+ uint8_t tile[16];
468
+ for (r = 8; r < 16; r++) tile[r] = 0; /* plane 1 = 0 (colour 1) */
469
+ for (g = 0; g < 37; g++) {
470
+ for (r = 0; r < 8; r++) tile[r] = font8[g][r];
471
+ chr_ram_upload((uint16_t)(0x1000 + ((FONT_BASE_TILE + g) << 4)), tile, 16);
472
+ }
473
+ }
474
+
475
+ /* char → BG tile id (space → tile 0 = blank). */
476
+ static uint8_t font_tile(char ch) {
477
+ if (ch >= '0' && ch <= '9') return (uint8_t)(FONT_BASE_TILE + (ch - '0'));
478
+ if (ch >= 'A' && ch <= 'Z') return (uint8_t)(FONT_BASE_TILE + 10 + (ch - 'A'));
479
+ if (ch >= 'a' && ch <= 'z') return (uint8_t)(FONT_BASE_TILE + 10 + (ch - 'a'));
480
+ if (ch == '-') return (uint8_t)(FONT_BASE_TILE + 36);
481
+ return 0;
482
+ }
483
+
484
+ void text_draw_unsafe(uint16_t ppu_addr, const char *s) {
485
+ while (*s) vram_unsafe_set(ppu_addr++, font_tile(*s++));
486
+ }
487
+
488
+ void text_draw(uint8_t nt, uint8_t x, uint8_t y, const char *s) {
489
+ while (*s) tile_set(nt, x++, y, font_tile(*s++));
490
+ }
491
+
492
+ void text_draw_u16(uint8_t nt, uint8_t x, uint8_t y, uint16_t v) {
493
+ uint8_t d[5];
494
+ uint8_t i;
495
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
496
+ for (i = 0; i < 5; i++) tile_set(nt, (uint8_t)(x + i), y, (uint8_t)(FONT_BASE_TILE + d[4 - i]));
497
+ }
498
+
499
+ /* ════════════════════════════════════════════════════════════════════
500
+ * Hi-score persistence (battery PRG-RAM at $6000)
501
+ * ════════════════════════════════════════════════════════════════════ */
502
+
503
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
504
+ * Requires: the iNES BATTERY flag in the crt0 header (flags6 bit 1 — the
505
+ * bundled chr-ram-runtime crt0 sets it). Without it, NROM leaves
506
+ * $6000-$7FFF UNMAPPED: reads return open bus (looks like data, isn't),
507
+ * writes vanish, and nothing persists. With it the emulator maps 8KB
508
+ * persistent PRG-RAM there (the save_ram region) like a real battery cart.
509
+ * First boot is GARBAGE, not zeros — that's why the magic + checksum. */
510
+ #define SRAM ((volatile uint8_t *)0x6000)
511
+
512
+ uint16_t hiscore_load(void) {
513
+ uint16_t v;
514
+ if (SRAM[0] != 'H' || SRAM[1] != 'S') return 0;
515
+ v = (uint16_t)SRAM[2] | ((uint16_t)SRAM[3] << 8);
516
+ if (SRAM[4] != (uint8_t)(SRAM[2] ^ SRAM[3] ^ 0xA5)) return 0;
517
+ return v;
518
+ }
519
+
520
+ void hiscore_save(uint16_t v) {
521
+ SRAM[0] = 'H'; SRAM[1] = 'S';
522
+ SRAM[2] = (uint8_t)(v & 0xFF);
523
+ SRAM[3] = (uint8_t)(v >> 8);
524
+ SRAM[4] = (uint8_t)(SRAM[2] ^ SRAM[3] ^ 0xA5);
525
+ }
@@ -146,10 +146,43 @@ void sound_play_tone(uint8_t channel, uint16_t period, uint8_t vol_4bit, uint8_t
146
146
  void sound_play_noise(uint8_t period_4bit, uint8_t vol_4bit, uint8_t length_frames);
147
147
  void sound_off(void);
148
148
  void sound_music(uint8_t on); /* background triangle melody — ON by default; 0 = off */
149
- void sound_music_tick(void); /* call once per frame (scaffolds do) */
149
+ void sound_music_tick(void); /* call once per frame (the example games do) */
150
150
 
151
151
  /* ── Globals ──────────────────────────────────────────────────── */
152
152
  extern uint8_t shadow_oam[256]; /* at $0200, DMA'd by NMI */
153
153
  extern volatile uint8_t nmi_counter; /* increments each NMI */
154
154
 
155
+ /* ── Text + font (0.29.0 examples contract) ─────────────────────── */
156
+ /*
157
+ * font_upload()
158
+ * Upload the built-in 8x8 font (digits 0-9, A-Z, space, dash) into the
159
+ * BACKGROUND pattern table at tile $40+ ('0'-'9' = $40-$49, 'A'-'Z' =
160
+ * $4A-$63, '-' = $64; space maps to tile 0). Call once during init
161
+ * (PPU off), after your other CHR uploads.
162
+ *
163
+ * text_draw_unsafe(ppu_addr, s) — PPU OFF only (init/title paint).
164
+ * text_draw(nt, x, y, s) — queued, safe during rendering (NMI
165
+ * commits next vblank; 16-entry queue).
166
+ * text_draw_u16(nt, x, y, v) — 5 right-aligned decimal digits (queued).
167
+ */
168
+ void font_upload(void);
169
+ void text_draw_unsafe(uint16_t ppu_addr, const char *s);
170
+ void text_draw(uint8_t nt, uint8_t x, uint8_t y, const char *s);
171
+ void text_draw_u16(uint8_t nt, uint8_t x, uint8_t y, uint16_t v);
172
+
173
+ /* ── Hi-score persistence (battery PRG-RAM at $6000) ────────────── */
174
+ /*
175
+ * The bundled chr-ram-runtime crt0 sets the iNES BATTERY flag, so the
176
+ * emulator maps 8KB persistent PRG-RAM at $6000-$7FFF (the save_ram
177
+ * region) and persists it like a real battery cart. Layout used here:
178
+ * $6000-$6001 magic "HS", $6002-$6003 score (LE), $6004 checksum
179
+ * (score lo ^ score hi ^ $A5).
180
+ *
181
+ * hiscore_load() → the saved score, or 0 when the SRAM is empty/corrupt
182
+ * (first boot reads open-bus-like garbage — the magic+checksum reject it).
183
+ * hiscore_save(v) → store v. Call when a run ends with a new record.
184
+ */
185
+ uint16_t hiscore_load(void);
186
+ void hiscore_save(uint16_t v);
187
+
155
188
  #endif /* NES_RUNTIME_H */
@@ -12,13 +12,13 @@ romdev ships a **hardware helper library** (`src/platforms/pce/lib/c/`:
12
12
  `psg_tone()` instead of poking VDC/VCE registers by hand. cc65 has **no** sprite
13
13
  library, so this lib is how you get pixels on screen.
14
14
 
15
- The fastest way to a working game: **`scaffold({op:'game', platform: "pce", genre:
16
- "shmup"})`** — or any of `platformer` / `puzzle` / `sports` / `racing`, the full
17
- genre set. For a smaller starting point use **`scaffold({op:'project', platform:
18
- "pce", template: "sprite_move"})`** (also `music_sfx`, `catch_game`). Either drops
15
+ The fastest way to a working game: **fork the example game whose core loop is
16
+ nearest yours — `examples({op:'fork', example:"pce/shmup", name, path})`** — or any
17
+ of `platformer` / `puzzle` / `sports` / `racing`, the full genre set. For a smaller
18
+ starting point fork `pce/sprite_move` (also `music_sfx`, `catch_game`). Either drops
19
19
  a complete, *building* project — a verified playable example + the helper lib +
20
20
  docs. Read the example's `main.c`, then change it. The examples live in
21
- `examples/pce/`. The genre scaffolds fill the BAT (32×32 virtual screen); the
21
+ `examples/pce/`. The genre examples fill the BAT (32×32 virtual screen); the
22
22
  `platformer` smooth-scrolls the background via the VDC BXR (R7) register.
23
23
  **Gotcha:** `#include <stdint.h>` for int8/16/32_t — `pce.h` only typedefs u8/u16.
24
24
 
@@ -66,4 +66,4 @@ The 5-bit channel volume (`PSG_CHAN_CTRL` low bits, 0-31) is roughly an
66
66
  ATTENUATOR: each step below 31 costs ~1.5 dB. A "middle" value like 13 is
67
67
  about -27 dB — effectively silence on real hardware and most cores. Use
68
68
  **29-31 for SFX/music** and treat anything under ~20 as a deliberate whisper.
69
- (The bundled `psg_tone` scaffold helper and the music ticker default loud.)
69
+ (The bundled `psg_tone` example helper and the music ticker default loud.)
@@ -103,8 +103,19 @@ void disp_enable(void); /* VDC R5: BG + SPR + VBlank IRQ on at once
103
103
  void vblank_irq_enable(void); /* just the VBlank IRQ bit (waitvsync needs it) */
104
104
  void load_tiles(u16 vram, const u16 *src, u16 n); /* alias of vram_write (tiles) */
105
105
  void set_sprite(u8 slot, u16 x, u16 y, u16 pattern, u8 palette); /* fill shadow SATB */
106
+ void set_sprite_ex(u8 slot, u16 x, u16 y, u16 pattern, u8 palette, u16 attr_ex);
106
107
  void satb_dma(void); /* DMA shadow SATB -> VDC (R19) */
107
108
 
109
+ /* attr_ex bits for set_sprite_ex() — the HuC6270 large-sprite size + flip
110
+ * bits in SATB word3. A 32-wide sprite needs a 2-aligned pattern code, 32x32
111
+ * needs 4-aligned, 32x64 needs 8-aligned; the data is consecutive 16x16 cells
112
+ * (left-to-right, then down). See the set_sprite_ex() comment in pce_video.c. */
113
+ #define SPR_CGX_32 0x0100 /* width 32px (two cells side by side) */
114
+ #define SPR_CGY_32 0x1000 /* height 32px (two cell rows) */
115
+ #define SPR_CGY_64 0x3000 /* height 64px (four cell rows) */
116
+ #define SPR_XFLIP 0x0800 /* mirror horizontally */
117
+ #define SPR_YFLIP 0x8000 /* mirror vertically */
118
+
108
119
  /* The shadow SATB lives in VRAM at this word address; satb_dma() points the VDC
109
120
  * SATB-DMA source (R19) here. Pattern base for tiles is your choice; sprites
110
121
  * default to using the same VRAM you load_tiles() into. */
@@ -131,6 +131,38 @@ void set_sprite(u8 slot, u16 x, u16 y, u16 pattern, u8 palette) {
131
131
  e[3] = (u16)(0x0080 | (palette & 0x0F)); /* word3: SPBG-front + pal */
132
132
  }
133
133
 
134
+ /* set_sprite() with the HuC6270's LARGE-SPRITE size bits — the PCE's signature
135
+ * trick (sprites up to 32x64 from ONE SATB entry, where the NES needs 8+).
136
+ *
137
+ * SATB word3 (the attribute word) layout:
138
+ * bit 15 Y-flip
139
+ * bits13:12 CGY — sprite HEIGHT: 00=16px, 01=32px, 11=64px (10 is invalid)
140
+ * bit 11 X-flip
141
+ * bit 8 CGX — sprite WIDTH: 0=16px, 1=32px
142
+ * bit 7 SPBG — 1 = sprite in front of background
143
+ * bits 3:0 sprite sub-palette (0-15)
144
+ *
145
+ * `attr_ex` is OR'd into word3 — pass the SPR_* constants from pce_hw.h
146
+ * (e.g. SPR_CGX_32 | SPR_CGY_32 for a 32x32 sprite). SPBG-front is still set
147
+ * for you, same as set_sprite().
148
+ *
149
+ * PATTERN LAYOUT for large sprites: the hardware ignores the low bit(s) of the
150
+ * pattern code and fetches consecutive 16x16 cells (64 words each) instead:
151
+ * 32 wide: cell N = left, N+1 = right (N multiple of 2)
152
+ * 32 tall: row r adds 2*r: N, N+1 / N+2, N+3 (N multiple of 4)
153
+ * 64 tall: rows 0-3 add 2*r: N .. N+7 (N multiple of 8)
154
+ * So a 32x32 sprite's VRAM data is FOUR cells in TL, TR, BL, BR order, and
155
+ * its pattern code must be 4-aligned (pattern = VRAM>>6 like set_sprite). */
156
+ void set_sprite_ex(u8 slot, u16 x, u16 y, u16 pattern, u8 palette, u16 attr_ex) {
157
+ u16 *e;
158
+ if (slot >= 64) return;
159
+ e = &_pce_satb[slot * 4];
160
+ e[0] = (u16)(y + 64); /* word0: Y (biased) */
161
+ e[1] = (u16)(x + 32); /* word1: X (biased) */
162
+ e[2] = (u16)((pattern & 0x3FF) << 1); /* word2: pattern (cell<<1) */
163
+ e[3] = (u16)(0x0080 | (palette & 0x0F) | attr_ex); /* word3: size/flip + SPBG + pal */
164
+ }
165
+
134
166
  /* Copy the shadow SATB into VRAM at PCE_SATB_VRAM, then tell the VDC to DMA it
135
167
  * into its internal sprite table (R19 = SATB source). */
136
168
  void satb_dma(void) {
@@ -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 = 0xFF sprite tile data at $2000 (own bank; scaffolds upload here)
81
+ R6 = 0xFF sprite tile data at $2000 (own bank; the example games upload here)
82
82
  R7 = 0x00 border colour
83
83
  ```
84
84
 
@@ -114,7 +114,7 @@ So Y bytes and X/tile pairs are split into TWO regions of the SAT.
114
114
  `src/platforms/sms/lib/c/sprite_table.c` keeps a 256-byte shadow
115
115
  buffer in WRAM and uploads it to the SAT each vblank.
116
116
 
117
- ### Two footguns the bundled scaffolds keep hitting
117
+ ### Two footguns the bundled example games keep hitting
118
118
 
119
119
  1. **8 sprites per scanline limit.** The VDP draws up to 8 sprites per
120
120
  scanline; the 9th+ are silently dropped. If you draw a "CATCH THE
@@ -147,9 +147,9 @@ buffer in WRAM and uploads it to the SAT each vblank.
147
147
  `sms_vdp_init()` sets R6 = 0xFF. R6 bit 2 is the SA13 select for
148
148
  sprite tile data — bit 2 is **SET** in 0xFF, so sprite tiles read
149
149
  from `$2000-$3FFF`, their **own bank** separate from BG tiles at
150
- $0000. This matches every bundled scaffold, which uploads sprite
150
+ $0000. This matches every bundled example, which uploads sprite
151
151
  tiles to `$2000` (`sms_load_tiles(0x2000, …)`) — default and
152
- scaffolds agree, so sprites render.
152
+ examples agree, so sprites render.
153
153
 
154
154
  Watch the bit: 0xFB has SA13 **CLEAR** = sprite tiles at $0000
155
155
  (shared with the BG bank). If you set R6=0xFB you MUST upload your
@@ -222,7 +222,7 @@ PSG (SN76489) on port $7F. 4 channels: 3 square waves + 1 noise.
222
222
  Writes are byte-wise; the high bit selects "latch register" vs
223
223
  "continue previous register".
224
224
 
225
- A full driver is beyond the scope of these scaffolds. For
225
+ A full driver is beyond the scope of these example games. For
226
226
  playable SFX, manually pulse $7F with the latch-register byte
227
227
  followed by data bytes. Real games ship a music driver in WRAM.
228
228
 
@@ -325,7 +325,7 @@ region per bank with a bank-by-bank native rebuild recipe in `BUILD.md`.
325
325
 
326
326
  ## Horizontal scrolling (for side-scrollers)
327
327
 
328
- The `platformer` scaffold is single-screen. To make it a side-scroller:
328
+ The `platformer` example is single-screen. To make it a side-scroller:
329
329
 
330
330
  - **Hardware scroll:** write VDP register 8 (horizontal scroll) each frame =
331
331
  `-camX & 0xFF` (the reg scrolls the screen; the name table is 32×28 and
@@ -253,7 +253,7 @@ PVSnesLib's `hdr.asm` fills these in.
253
253
 
254
254
  ## Where the SDK lives (and how to read it)
255
255
 
256
- `scaffold({op:'project', platform:"snes"})` ships the FULL PVSnesLib source +
256
+ `examples({op:'fork'})` (any SNES example) ships the FULL PVSnesLib source +
257
257
  header tree into the new project at `vendor/pvsneslib/`. So when
258
258
  your code does `#include <snes.h>`, those headers come from
259
259
  `vendor/pvsneslib/include/`:
@@ -296,7 +296,7 @@ Loadable via snes9x (`loadMedia`).
296
296
 
297
297
  ## Horizontal scrolling (for side-scrollers)
298
298
 
299
- The `platformer` scaffold is single-screen. SNES scrolling is the easiest of
299
+ The `platformer` example is single-screen. SNES scrolling is the easiest of
300
300
  the tile platforms because each BG layer has its own hardware scroll register
301
301
  and parallax is nearly free.
302
302
 
@@ -160,6 +160,45 @@ Three layers:
160
160
  PVSnesLib's API is the path of least resistance. Roll your own SPC
161
161
  driver only when you really need the control.
162
162
 
163
+ ## "Music never starts (sfx works, sfx_init returned 0)"
164
+
165
+ A command sent to the bundled snes_sfx driver IMMEDIATELY after
166
+ `sfx_init()` returns can be silently swallowed. `sfx_init` returns the
167
+ instant the SPC echoes the jump command, but the driver then spends
168
+ ~50 DSP port writes initialising before it seeds its command
169
+ edge-detector from $2140. A `sfx_music_play()` issued inside that
170
+ window becomes the SEED — no edge, no dispatch, music never starts.
171
+
172
+ Symptoms via the debug tools: `getAudioState({chip:'dsp'})` shows
173
+ voice 1 with pitch 0 / env 0; ARAM $00 (prev_cmd) already equals your
174
+ command byte while ARAM $01 (music_on) is 0.
175
+
176
+ Fix: put one `WaitForVBlank()` between `sfx_init()` and the first
177
+ `sfx_play`/`sfx_music_play` — a frame is thousands of SPC cycles, the
178
+ driver is guaranteed to be in its command loop. The racing example does
179
+ exactly this (see its `sfx_init` call site).
180
+
181
+ ## "My HDMA table stops landing / OAM gets corrupted" (HDMA channel fights the OAM DMA)
182
+
183
+ A DMA channel cannot serve general-purpose DMA and HDMA in the same
184
+ frame — and PVSnesLib's runtime OWNS two channels for GP-DMA:
185
+
186
+ - **channel 0** — `dmaCopyVram` and friends (console text upload,
187
+ `oamInitGfxSet`, `consoleVblank`)
188
+ - **channel 7** — the VBlank ISR's OAM upload (vblank.asm rewrites
189
+ $4370-$4375 EVERY NMI)
190
+
191
+ Park an HDMA effect on channel 7 and it works for exactly zero frames:
192
+ each NMI silently rewrites the channel's DMAP/BBAD/A1T with OAM-DMA
193
+ parameters, so your per-scanline writes stop landing — and worse, the
194
+ HDMA unit then feeds your table bytes into $2104 (OAM data). The
195
+ failure is maddeningly partial: channels 1-6 keep working, so a
196
+ multi-channel effect (e.g. a Mode 7 split) comes up ALMOST right with
197
+ one register mysteriously stuck at a stale value.
198
+
199
+ Fix: keep HDMA on channels 1-6. The Mode 7 racing example uses 2-6 and
200
+ documents the assignment at its `road_hdma_on()`.
201
+
163
202
  ## "consoleDrawText output is corrupt / shifted"
164
203
 
165
204
  `consoleInitText(palnum, palsize, tilfont, palfont)` configures the
@@ -220,7 +259,7 @@ synthesizes a fallback `issues[]` entry with a hint. The idioms to avoid:
220
259
  crossed a bank boundary and the layout is wrong. Native interrupt vectors live
221
260
  at `$FFE4-$FFEE`, emulation vectors at `$FFF4-$FFFF` — keep your header/vector
222
261
  block where the layout expects it. Use
223
- `scaffold({op:'snippets', platform:"snes", mode:"get", name:"lorom_header.asm"})`
262
+ `examples({op:'snippets', platform:"snes", mode:"get", snippetName:"lorom_header.asm"})`
224
263
  for the canonical layout (and `lorom_multibank.asm` for multi-bank).
225
264
 
226
265
  (This is the asar/asm path. The default PVSnesLib **C** path goes through