romdevtools 0.27.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -323,6 +323,43 @@ export function letterbox(winW, winH, targetAspect) {
323
323
  };
324
324
  }
325
325
 
326
+ // How recently (in window ticks ≈ frames at 60fps real time) the human must
327
+ // have pressed something for the session to count as "human input active".
328
+ // 120 ticks ≈ 2 s — long enough to span the natural gaps WITHIN active play
329
+ // (between taps), short enough that an agent isn't warned off long after the
330
+ // human set the pad down.
331
+ export const HUMAN_INPUT_ACTIVE_FRAMES = 120;
332
+
333
+ /**
334
+ * Any button held in a built input-port object? The C64 virtual keys
335
+ * (c64_f1 …) count too — any truthy value is a press.
336
+ * @param {Record<string, boolean>} port
337
+ */
338
+ export function anyButtonHeld(port) {
339
+ for (const k in port) if (port[k]) return true;
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Pure "when did the human last actually press something" tracker behind the
345
+ * co-drive detection. The tick loop calls note() every unpaused frame; the
346
+ * session handle (and through it catalog/frame/input warnings) asks active()/
347
+ * framesSince(). Pure + exported so the activity contract is unit-testable
348
+ * without an SDL window.
349
+ * @param {number} [activeWindow] ticks within which a press counts as active
350
+ */
351
+ export function createHumanInputTracker(activeWindow = HUMAN_INPUT_ACTIVE_FRAMES) {
352
+ let lastTick = null;
353
+ return {
354
+ /** @param {boolean} pressing @param {number} tick */
355
+ note(pressing, tick) { if (pressing) lastTick = tick; },
356
+ /** @param {number} tick @returns {number | null} null = never pressed */
357
+ framesSince(tick) { return lastTick == null ? null : Math.max(0, tick - lastTick); },
358
+ /** @param {number} tick */
359
+ active(tick) { return lastTick != null && tick - lastTick <= activeWindow; },
360
+ };
361
+ }
362
+
326
363
  function tvAspectFor(platform, displayAspect) {
327
364
  switch (platform) {
328
365
  case "nes":
@@ -505,6 +542,15 @@ export async function playtest(args) {
505
542
  let closeResolver = null;
506
543
  const closedPromise = new Promise((r) => { closeResolver = r; });
507
544
 
545
+ // Human co-drive detection. tickCount advances every tick (even paused /
546
+ // mid-rebuild) so "frames since the human pressed" tracks wall time at
547
+ // ~60fps. humanInputDirty = the host's input state currently holds buttons
548
+ // WE wrote for the human — it buys exactly one release write after they let
549
+ // go, after which an idle window leaves the agent's setInput alone.
550
+ let tickCount = 0;
551
+ const humanInput = createHumanInputTracker();
552
+ let humanInputDirty = false;
553
+
508
554
  // Track pixel-size from resize events instead of polling window.width every
509
555
  // tick — that's the retroemu pattern. window.pixelWidth/height is the real
510
556
  // backing-store size (which is what dstRect cares about); on HiDPI it
@@ -581,6 +627,7 @@ export async function playtest(args) {
581
627
 
582
628
  function tick() {
583
629
  if (!running || window.destroyed) { stop(); return; }
630
+ tickCount++;
584
631
  // Resolve the session's CURRENT host this frame. A `runSource`/`loadMedia`
585
632
  // rebuild swapped it; we follow it so the window shows the latest build.
586
633
  // If there's transiently no host or no media loaded (mid-swap), skip this
@@ -601,8 +648,9 @@ export async function playtest(args) {
601
648
  const paused = !!h.status.paused || !!h._renderTickSuspended;
602
649
  // Read controller state for each slot independently. Slot 0 = port 0
603
650
  // (player 1), slot 1 = port 1 (player 2). Each slot's input is built
604
- // into its own port object; the agent's setInput is overwritten each
605
- // tick (matching prior behavior). Select+Start on any controller quits.
651
+ // into its own port object. The agent's setInput is only overwritten
652
+ // while the human is ACTUALLY pressing (see the write below) an idle
653
+ // window leaves it alone. Select+Start on any controller quits.
606
654
  let quit = false;
607
655
  const isC64 = h.status?.platform === "c64";
608
656
  function readControllerInto(port, inst) {
@@ -667,7 +715,12 @@ export async function playtest(args) {
667
715
  if (heldKeys.has(keyName)) port0[vbtn] = true;
668
716
  }
669
717
  }
718
+ // Did the human actually press anything this tick (pad or keyboard,
719
+ // either port)? Rewind-scrubbing counts as activity too — the human is
720
+ // actively manipulating emulator state even though R maps to no button.
721
+ const humanPressing = anyButtonHeld(port0) || anyButtonHeld(port1);
670
722
  const isRewinding = heldKeys.has("r") && rewindBuffer.length > 0;
723
+ humanInput.note(humanPressing || isRewinding, tickCount);
671
724
  if (isRewinding) {
672
725
  // Restore the previous snapshot and run one frame to produce its visual.
673
726
  const snap = rewindBuffer.pop();
@@ -687,7 +740,16 @@ export async function playtest(args) {
687
740
  if (rewindBuffer.length > MAX_REWIND_FRAMES) rewindBuffer.shift();
688
741
  } catch {}
689
742
  }
690
- h.setInput({ ports: [port0, port1] });
743
+ // Write input ONLY while the human is actually pressing, plus ONE
744
+ // release write after they let go (humanInputDirty). The old behavior
745
+ // wrote all-zeros EVERY tick, which silently clobbered the agent's
746
+ // input({op:'set'}) even when nobody was touching the pad. An idle
747
+ // window now leaves the host's input state alone; the human still
748
+ // wins the instant they press.
749
+ if (humanPressing || humanInputDirty) {
750
+ h.setInput({ ports: [port0, port1] });
751
+ humanInputDirty = humanPressing;
752
+ }
691
753
  let stepped = 0;
692
754
  try {
693
755
  stepped = h.stepFrames(1);
@@ -817,6 +879,14 @@ export async function playtest(args) {
817
879
  // hot-plug), so a caller can decide whether to surface the keyboard help.
818
880
  // 0 → the user has no pad and is on the keyboard fallback.
819
881
  get controllerCount() { return controllers.filter(Boolean).length; },
882
+ // Human co-drive detection: has the human pressed anything (pad, keyboard,
883
+ // or rewind-scrub) within the last ~2 s of window ticks? Drives the
884
+ // catalog({op:'status'}) flags and the frame/input co-drive warnings so an
885
+ // agent KNOWS when a human is driving the same emulator.
886
+ humanInputActive() { return humanInput.active(tickCount); },
887
+ // Ticks (≈ frames at 60fps real time) since the last human press; null if
888
+ // the human hasn't touched anything since the window opened.
889
+ framesSinceHumanInput() { return humanInput.framesSince(tickCount); },
820
890
  // The emulator host the window is CURRENTLY rendering. The window follows
821
891
  // the session's live host (a `runSource`/`loadMedia` rebuild updates it in
822
892
  // place), so this is whatever the human is looking at right now. Exposed so
@@ -19,13 +19,15 @@
19
19
  # 3. Write CHR data from C at runtime: PPUADDR = 0x00; PPUDATA = byte; etc.
20
20
 
21
21
  SYMBOLS {
22
- # Stack is $0200 (512 B) so the top RAM page ($0700-$07FF) can be
23
- # reserved below for a music driver's scratch RAM (FamiTone2 et al.),
24
- # which needs a dedicated, page-aligned block that the C BSS/DATA
25
- # region must NOT overlap. Tiny NROM scaffolds use far less than 512 B
26
- # of stack, so this is safe; scaffolds with no music driver simply
27
- # leave the reserved page unused.
28
- __STACKSIZE__: type = weak, value = $0200;
22
+ # C parameter stack is ONE page ($0600-$06FF, grows down from $0700).
23
+ # NROM-sized C games use far less than 256 B of it (shallow call
24
+ # depth, mostly static data), and shrinking it frees $0500-$05FF as
25
+ # the USER SCRATCH PAGE: game code may place absolute-addressed
26
+ # arrays there (e.g. `#define BOARD ((unsigned char*)0x0500)`) when
27
+ # BSS ($0300-$04FF) is full — the puzzle example game does exactly
28
+ # this. The top page ($0700-$07FF) stays reserved for a music
29
+ # driver's scratch RAM (FamiTone2 et al.).
30
+ __STACKSIZE__: type = weak, value = $0100;
29
31
  }
30
32
  MEMORY {
31
33
  ZP: file = "", start = $0002, size = $001A, type = rw, define = yes;
@@ -41,7 +43,10 @@ MEMORY {
41
43
 
42
44
  # NO ROM2 / CHARS — this is the whole point of the CHR-RAM preset.
43
45
 
44
- SRAM: file = "", start = $0500, size = __STACKSIZE__, define = yes;
46
+ # $0500-$05FF: user scratch page (see SYMBOLS note) NOT a segment;
47
+ # game code addresses it absolutely so the linker never places
48
+ # anything here.
49
+ SRAM: file = "", start = $0600, size = __STACKSIZE__, define = yes;
45
50
 
46
51
  # Reserved page for a sound-driver's RAM scratch ($0700-$07FF). The
47
52
  # bundled FamiTone2 engine (music_demo scaffold) pins FT_BASE_ADR here
@@ -28,7 +28,12 @@
28
28
  .import _main, zerobss, copydata
29
29
  .import __RAM_START__, __RAM_SIZE__
30
30
  .import __SRAM_START__, __SRAM_SIZE__
31
- .import _vram_queue_flush
31
+ .import _vram_q_hi, _vram_q_lo, _vram_q_val
32
+ .import _vram_queue_head, _vram_queue_len, _vram_queue_lock
33
+
34
+ ; Must match nes_runtime.c (QUEUE_MAX 32 ring buffer).
35
+ QUEUE_MASK = 31
36
+ FLUSH_BUDGET = 16
32
37
  .import _scroll_x, _scroll_y, _ppuctrl_value, _nmi_counter
33
38
  .importzp c_sp
34
39
 
@@ -39,7 +44,12 @@
39
44
  .byte $4e, $45, $53, $1a ; "NES" + EOF
40
45
  .byte 2 ; PRG-ROM banks (16K each) → 32K
41
46
  .byte 0 ; CHR-ROM banks (8K each) → 0 = CHR-RAM
42
- .byte %00000001 ; flags6 — vertical mirroring
47
+ .byte %00000011 ; flags6 — vertical mirroring + BATTERY.
48
+ ; The battery bit maps persistent 8KB
49
+ ; PRG-RAM at $6000 (the save_ram region)
50
+ ; — hiscore_load/save in nes_runtime use
51
+ ; it. Benign when unused; without it,
52
+ ; $6000-$7FFF is OPEN BUS on NROM.
43
53
  .byte %00000000 ; flags7 — mapper hi nybble
44
54
  .byte 0, 0, 0, 0, 0, 0, 0, 0
45
55
 
@@ -129,9 +139,46 @@ nmi:
129
139
  lda #$02 ; high byte of $0200
130
140
  sta $4014 ; PPU OAMDMA — kicks off the copy
131
141
 
132
- ; Flush the VRAM queue (writes anything game code stashed via
133
- ; vram_set / tile_set / tile_set_palette). PPU is unlocked here.
134
- jsr _vram_queue_flush
142
+ ; ── Drain the VRAM queue IN ASSEMBLY, on purpose ──────────────
143
+ ; Vblank is ~2273 CPU cycles and the OAM DMA above just spent 513.
144
+ ; Compiled C costs 200+ cycles per queue entry, so a C flush blows
145
+ ; past the end of vblank — and PPUDATA writes during ACTIVE
146
+ ; RENDERING land at corrupted addresses (the PPU's internal v
147
+ ; register is busy fetching tiles; its coarse-X/fine-Y counters
148
+ ; shear every late write). This loop costs ~40 cycles per entry,
149
+ ; so FLUSH_BUDGET entries always finish safely inside vblank.
150
+ ; QUEUE_MASK/FLUSH_BUDGET must match nes_runtime.c's ring buffer.
151
+ lda _vram_queue_lock
152
+ bne @flush_done ; a push is mid-flight — skip this vblank
153
+ lda _vram_queue_len
154
+ beq @flush_done
155
+ cmp #FLUSH_BUDGET
156
+ bcc @flush_n_ok
157
+ lda #FLUSH_BUDGET
158
+ @flush_n_ok:
159
+ sta nmi_drain ; loop counter
160
+ sta nmi_drained ; remembered for the length update
161
+ bit $2002 ; reset the PPUADDR write latch
162
+ ldx _vram_queue_head
163
+ @flush_loop:
164
+ lda _vram_q_hi,x
165
+ sta $2006
166
+ lda _vram_q_lo,x
167
+ sta $2006
168
+ lda _vram_q_val,x
169
+ sta $2007
170
+ inx
171
+ txa
172
+ and #QUEUE_MASK ; ring wrap
173
+ tax
174
+ dec nmi_drain
175
+ bne @flush_loop
176
+ stx _vram_queue_head
177
+ lda _vram_queue_len
178
+ sec
179
+ sbc nmi_drained
180
+ sta _vram_queue_len
181
+ @flush_done:
135
182
 
136
183
  ; Reset PPUADDR to $2000 (otherwise the queue's last $2006 write
137
184
  ; leaves it dangling and the PPU samples random VRAM as the BG).
@@ -172,6 +219,12 @@ irq: rti
172
219
  _shadow_oam: .res 256
173
220
 
174
221
  ; ------------------------------------------------------------------------
222
+ ; NMI-private temporaries — deliberately NOT cc65's zp tmp1-4 (the NMI
223
+ ; would corrupt them under interrupted C code).
224
+ .segment "BSS"
225
+ nmi_drain: .res 1
226
+ nmi_drained: .res 1
227
+
175
228
  .segment "VECTORS"
176
229
  .word nmi ; $FFFA
177
230
  .word start ; $FFFC
@@ -22,7 +22,12 @@
22
22
  .import _main, zerobss, copydata
23
23
  .import __RAM_START__, __RAM_SIZE__
24
24
  .import __SRAM_START__, __SRAM_SIZE__
25
- .import _vram_queue_flush
25
+ .import _vram_q_hi, _vram_q_lo, _vram_q_val
26
+ .import _vram_queue_head, _vram_queue_len, _vram_queue_lock
27
+
28
+ ; Must match nes_runtime.c (QUEUE_MAX 32 ring buffer).
29
+ QUEUE_MASK = 31
30
+ FLUSH_BUDGET = 16
26
31
  .import _scroll_x, _scroll_y, _ppuctrl_value, _nmi_counter
27
32
  .importzp c_sp
28
33
 
@@ -109,8 +114,46 @@ nmi:
109
114
  lda #$02 ; high byte of $0200
110
115
  sta $4014 ; PPU OAMDMA — kicks off the copy
111
116
 
112
- ; Flush the VRAM queue (nametable/palette writes game code stashed).
113
- jsr _vram_queue_flush
117
+ ; ── Drain the VRAM queue IN ASSEMBLY, on purpose ──────────────
118
+ ; Vblank is ~2273 CPU cycles and the OAM DMA above just spent 513.
119
+ ; Compiled C costs 200+ cycles per queue entry, so a C flush blows
120
+ ; past the end of vblank — and PPUDATA writes during ACTIVE
121
+ ; RENDERING land at corrupted addresses (the PPU's internal v
122
+ ; register is busy fetching tiles; its coarse-X/fine-Y counters
123
+ ; shear every late write). This loop costs ~40 cycles per entry,
124
+ ; so FLUSH_BUDGET entries always finish safely inside vblank.
125
+ ; QUEUE_MASK/FLUSH_BUDGET must match nes_runtime.c's ring buffer.
126
+ lda _vram_queue_lock
127
+ bne @flush_done ; a push is mid-flight — skip this vblank
128
+ lda _vram_queue_len
129
+ beq @flush_done
130
+ cmp #FLUSH_BUDGET
131
+ bcc @flush_n_ok
132
+ lda #FLUSH_BUDGET
133
+ @flush_n_ok:
134
+ sta nmi_drain ; loop counter
135
+ sta nmi_drained ; remembered for the length update
136
+ bit $2002 ; reset the PPUADDR write latch
137
+ ldx _vram_queue_head
138
+ @flush_loop:
139
+ lda _vram_q_hi,x
140
+ sta $2006
141
+ lda _vram_q_lo,x
142
+ sta $2006
143
+ lda _vram_q_val,x
144
+ sta $2007
145
+ inx
146
+ txa
147
+ and #QUEUE_MASK ; ring wrap
148
+ tax
149
+ dec nmi_drain
150
+ bne @flush_loop
151
+ stx _vram_queue_head
152
+ lda _vram_queue_len
153
+ sec
154
+ sbc nmi_drained
155
+ sta _vram_queue_len
156
+ @flush_done:
114
157
 
115
158
  ; Reset PPUADDR to $2000 so the PPU doesn't sample random VRAM as BG.
116
159
  bit $2002
@@ -147,6 +190,12 @@ irq: rti
147
190
  _shadow_oam: .res 256
148
191
 
149
192
  ; ------------------------------------------------------------------------
193
+ ; NMI-private temporaries — deliberately NOT cc65's zp tmp1-4 (the NMI
194
+ ; would corrupt them under interrupted C code).
195
+ .segment "BSS"
196
+ nmi_drain: .res 1
197
+ nmi_drained: .res 1
198
+
150
199
  .segment "VECTORS"
151
200
  .word nmi ; $FFFA
152
201
  .word start ; $FFFC
@@ -0,0 +1,52 @@
1
+ # PC Engine 32KB HuCard ld65 config (romdev 'rom32k' preset).
2
+ #
3
+ # WHY THIS EXISTS: cc65's stock pce.cfg defaults to an 8KB image, and its
4
+ # documented 16K/32K option ($CARTSIZE) places STARTUP/VECTORS at the END of
5
+ # the file — but a HuCard maps file offset 0 as bank 0, and the HuC6280 reset
6
+ # maps MPR7 (=$E000-$FFFF, where the vectors live) to BANK 0. So a stock-cfg
7
+ # 32K image boots to a black screen (verified on geargrafx). This config puts
8
+ # bank 0 (STARTUP/VECTORS + hot code) FIRST in the file, at $E000, and the
9
+ # remaining 24KB of banks 1-3 at $8000-$DFFF — exactly where cc65's pce crt0
10
+ # TAMs them (MPR4=bank1, MPR5=bank2, MPR6=bank3) before calling main().
11
+ SYMBOLS {
12
+ __CARTSIZE__: type = weak, value = $8000; # crt0 compares >$8000 vs this
13
+ __STACKSIZE__: type = weak, value = $0300; # 3 pages stack
14
+ }
15
+ MEMORY {
16
+ ZP: file = "", start = $0000, define = yes, size = $0100;
17
+ # RAM bank ($F8 at MPR1)
18
+ MAIN: file = "", start = $2200, define = yes, size = $1E00 - __STACKSIZE__;
19
+ # HuCard bank 0 — hardware maps it at $E000 (MPR7) at reset. File offset 0.
20
+ ROM0: file = %O, start = $E000, size = $2000, fill = yes, fillval = $FF;
21
+ # HuCard banks 1-3 — crt0 maps them at $8000/$A000/$C000 (MPR4/5/6).
22
+ ROM: file = %O, start = $8000, size = $6000, fill = yes, fillval = $FF;
23
+ }
24
+ SEGMENTS {
25
+ ZEROPAGE: load = ZP, type = zp;
26
+ EXTZP: load = ZP, type = zp, optional = yes;
27
+ APPZP: load = ZP, type = zp, optional = yes;
28
+ DATA: load = ROM0, run = MAIN, type = rw, define = yes;
29
+ INIT: load = MAIN, type = bss, optional = yes;
30
+ BSS: load = MAIN, type = bss, define = yes;
31
+ LOWCODE: load = ROM0, type = ro, optional = yes;
32
+ ONCE: load = ROM0, type = ro, optional = yes;
33
+ CODE: load = ROM, type = ro;
34
+ RODATA: load = ROM, type = ro;
35
+ STARTUP: load = ROM0, type = ro, start = $FFF6 - $0066;
36
+ VECTORS: load = ROM0, type = ro, start = $FFF6;
37
+ }
38
+ FEATURES {
39
+ CONDES: type = constructor,
40
+ label = __CONSTRUCTOR_TABLE__,
41
+ count = __CONSTRUCTOR_COUNT__,
42
+ segment = ONCE;
43
+ CONDES: type = destructor,
44
+ label = __DESTRUCTOR_TABLE__,
45
+ count = __DESTRUCTOR_COUNT__,
46
+ segment = RODATA;
47
+ CONDES: type = interruptor,
48
+ label = __INTERRUPTOR_TABLE__,
49
+ count = __INTERRUPTOR_COUNT__,
50
+ segment = RODATA,
51
+ import = __CALLIRQ__;
52
+ }
@@ -37,7 +37,7 @@ const CC65_TARGET = {
37
37
  const LANGUAGE_TOOLCHAIN = {
38
38
  atari2600: {
39
39
  asm: { toolchain: "dasm", available: true },
40
- basic: { toolchain: "batariBasic", available: false, note: "BASIC for 2600 via batariBasic — not bundled. bB's transpiler is written in Perl, which we don't ship as WASM. A port to C or JS would be a multi-day project. For now, write 2600 games in 6507 asm via dasm — the bundled scaffolds (default, paddle, single_screen) show the canonical race-the-beam pattern, and an LLM agent writes 2600 asm fluently." },
40
+ basic: { toolchain: "batariBasic", available: false, note: "BASIC for 2600 via batariBasic — not bundled. bB's transpiler is written in Perl, which we don't ship as WASM. A port to C or JS would be a multi-day project. For now, write 2600 games in 6507 asm via dasm — the bundled example games (default, paddle, single_screen) show the canonical race-the-beam pattern, and an LLM agent writes 2600 asm fluently." },
41
41
  },
42
42
  nes: {
43
43
  asm: { toolchain: "cc65", available: true },
@@ -65,11 +65,11 @@ const LANGUAGE_TOOLCHAIN = {
65
65
  },
66
66
  snes: {
67
67
  asm: { toolchain: "asar", available: true },
68
- c: { toolchain: "tcc816+wladx", available: true, note: "C for SNES via tcc-65816 + wla-65816 + wlalink. The PVSnesLib runtime IS bundled (built from source) and auto-linked — #include <snes.h> gives you consoleDrawText, setMode, oamSet, WaitForVBlank, etc. out of the box. `createGame`/`createProject` scaffold a complete PVSnesLib C project. Pass options.pvsneslib:false for the bare-main minimum-viable path." },
68
+ c: { toolchain: "tcc816+wladx", available: true, note: "C for SNES via tcc-65816 + wla-65816 + wlalink. The PVSnesLib runtime IS bundled (built from source) and auto-linked — #include <snes.h> gives you consoleDrawText, setMode, oamSet, WaitForVBlank, etc. out of the box. `examples({op:'fork'})` gives you a complete working PVSnesLib C project. Pass options.pvsneslib:false for the bare-main minimum-viable path." },
69
69
  },
70
70
  genesis: {
71
71
  asm: { toolchain: "vasm68k", available: true },
72
- c: { toolchain: "m68k-elf-gcc", available: true, note: "C for Genesis via gcc 14.2.0 + binutils + newlib, all compiled to WASM. The SGDK runtime IS bundled (built from source) and auto-linked — sprite engine, VDP, controller, PSG/Z80 sound, resource helpers all work; #include <genesis.h>. `createGame`/`createProject` scaffold a complete SGDK C project (the recommended path). Pass options.sgdk:false for the bare-gcc minimum-viable path." },
72
+ c: { toolchain: "m68k-elf-gcc", available: true, note: "C for Genesis via gcc 14.2.0 + binutils + newlib, all compiled to WASM. The SGDK runtime IS bundled (built from source) and auto-linked — sprite engine, VDP, controller, PSG/Z80 sound, resource helpers all work; #include <genesis.h>. `examples({op:'fork'})` gives you a complete working SGDK C project (the recommended path). Pass options.sgdk:false for the bare-gcc minimum-viable path." },
73
73
  },
74
74
  gba: {
75
75
  c: { toolchain: "arm-none-eabi-gcc", available: true, note: "C for GBA via gcc 14.2.0 + binutils + newlib + libtonc 1.4.5 (default) OR libgba 0.5.4 (opt-in via runtime:\"libgba\"), all compiled to WASM (R24 + R28). #include <tonc.h> + tte_write/tte_printf works out of the box — that's the canonical Tonc-tutorial API every published GBA C resource uses. Caveat: tte_iohook (libtonc) and console.c (libgba) — the libsysbase-backed iprintf bridges — are NOT bundled. Use tte_printf directly, which is what the Tonc tutorial actually does." },
@@ -91,7 +91,7 @@ const LANGUAGE_TOOLCHAIN = {
91
91
  * Default language per platform. The choice reflects what's fastest /
92
92
  * smallest / best-matched to LLM fluency. Every platform that has a bundled
93
93
  * C compiler + runtime defaults to C — that's the canonical, productive path
94
- * and what `createGame`/`createProject` scaffold (cc65 for NES/C64/Atari7800/
94
+ * and what `examples({op:'fork'})` projects use (cc65 for NES/C64/Atari7800/
95
95
  * Lynx, SDCC for GB/GBC/SMS/GG, gcc+SGDK for Genesis, tcc+PVSnesLib for SNES,
96
96
  * gcc+libtonc for GBA). Platforms whose only bundled toolchain is an assembler
97
97
  * default to asm (Atari 2600 → dasm; SNES/Genesis keep an asm option too, but
@@ -727,16 +727,25 @@ export async function buildForPlatform(args) {
727
727
  // the cartridge header + reset vectors which the custom crt0 provides.
728
728
  // MSX: _CODE goes at $4010 — a cartridge maps at $4000-$BFFF and the first
729
729
  // 16 bytes are the ROM header ("AB" + INIT vector) the crt0 emits.
730
- const codeLoc = args.codeLoc ?? (args.platform === "msx" ? MSX_CODE_LOC : 0x0000);
730
+ // SMS/GG: _CODE goes at $0100 $0000-$00FF belongs to the crt0's ABS
731
+ // _HEADER area (reset + RST/IRQ/NMI vectors + _boot). The old default of
732
+ // $0000 linked _CODE ON TOP of the vector table: makebin emitted gsinit
733
+ // at $0000 and the di/im 1/SP-init/ISR vectors were GONE — it booted in a
734
+ // BIOS-less emulator by accident (gsinit happened to sit at the reset
735
+ // vector) but had no working IRQ/NMI/pause handling and was one EI away
736
+ // from jumping into garbage on real hardware.
737
+ const codeLoc = args.codeLoc ?? (
738
+ args.platform === "msx" ? MSX_CODE_LOC
739
+ : (args.platform === "sms" || args.platform === "gg") ? 0x0100
740
+ : 0x0000);
731
741
  const romSize = SDCC_ROM_SIZE[args.platform] ?? 32 * 1024;
732
742
 
733
743
  // crt0 + headers + sources come straight from the caller. The build
734
744
  // pipeline does NOT auto-inject platform runtimes, custom crt0s,
735
745
  // or post-link header patches. Every byte that compiles is visible
736
- // to the caller's repo. Use `createProject({platform, template})`
737
- // to scaffold a self-contained project with the runtime files
738
- // copied in, or call `getStarterSnippet` / `getAllStarterSnippets`
739
- // to fetch individual pieces.
746
+ // to the caller's repo. Use `examples({op:'fork'})` to get a
747
+ // self-contained project with the runtime files copied in, or
748
+ // `examples({op:'snippets'/'copySnippets'})` to fetch individual pieces.
740
749
  const crt0 = args.crt0;
741
750
 
742
751
  // Pre-flight lint: scan the C sources for known SDCC C89 violations
@@ -777,10 +786,27 @@ export async function buildForPlatform(args) {
777
786
  // and RAM-size ($0149) bytes — without -m/-r, -v leaves them at the
778
787
  // linker's garbage pad (e.g. type $3C), and emulators/hardware reject
779
788
  // an unknown MBC type with "retro_load_game failed". -m 0x00 = ROM ONLY
780
- // (no mapper), -r 0x00 = no cart RAM — correct for these 32KB scaffolds.
789
+ // (no mapper), -r 0x00 = no cart RAM — correct for plain 32KB builds.
790
+ //
791
+ // Battery-cart passthrough (0.29.0 examples): a crt0 may DECLARE the
792
+ // cart in the header window (the GB equivalent of the NES crt0's iNES
793
+ // BATTERY bit — see the gbc lib gb_crt0.s, which emits $0147=$03 /
794
+ // $0149=$02 for MBC1+RAM+BATTERY so hi-scores persist in SAVE_RAM).
795
+ // If the linked image carries a KNOWN battery-MBC type byte with a
796
+ // sane RAM size, pass those through to rgbfix instead of stomping
797
+ // them to ROM-only; anything unrecognized (linker pad garbage) still
798
+ // falls back to the safe ROM-only default, so crt0s that don't
799
+ // declare a cart behave exactly as before.
800
+ const BATTERY_CART_TYPES = new Set([0x03, 0x06, 0x0F, 0x10, 0x13, 0x1B, 0x1E]); // MBC1/2/3/5 +BATTERY variants
801
+ const declType = binary.length > 0x149 ? binary[0x147] : 0x00;
802
+ const declRam = binary.length > 0x149 ? binary[0x149] : 0x00;
803
+ const cartByte = BATTERY_CART_TYPES.has(declType) ? declType : 0x00;
804
+ const ramByte = cartByte !== 0x00 && declRam >= 0x01 && declRam <= 0x05 ? declRam : 0x00;
805
+ const mArg = "0x" + cartByte.toString(16).padStart(2, "0").toUpperCase();
806
+ const rArg = "0x" + ramByte.toString(16).padStart(2, "0").toUpperCase();
781
807
  const fixOpts = args.platform === "gbc"
782
- ? ["-v", "-p", "0xFF", "-C", "-m", "0x00", "-r", "0x00"]
783
- : ["-v", "-p", "0xFF", "-m", "0x00", "-r", "0x00"];
808
+ ? ["-v", "-p", "0xFF", "-C", "-m", mArg, "-r", rArg]
809
+ : ["-v", "-p", "0xFF", "-m", mArg, "-r", rArg];
784
810
  const fix = await runRgbfix({ rom: binary, options: fixOpts });
785
811
  if (fix.exitCode === 0 && fix.binary) {
786
812
  binary = fix.binary;
@@ -809,32 +835,51 @@ export async function buildForPlatform(args) {
809
835
  // rejected it. Checksum = sum of bytes $0000..$7FEF (everything before
810
836
  // the header), stored little-endian. GG BIOS doesn't check, but writing
811
837
  // it is harmless. Only touches ROMs that actually have the header.
812
- if (binary && r.exitCode === 0 && (args.platform === "sms" || args.platform === "gg") && binary.length >= 0x8000) {
838
+ if (binary && r.exitCode === 0 && (args.platform === "sms" || args.platform === "gg")) {
839
+ // Pad to a full 32KB bank FIRST. sdld emits up to the highest used
840
+ // address, so a small program can come out under $8000 — which (a)
841
+ // skipped this whole header block before (the header guard required
842
+ // 32KB) and (b) odd-size ROMs misbehave on real mappers/flashcarts.
843
+ if (binary.length < 0x8000) {
844
+ const padded = new Uint8Array(0x8000);
845
+ padded.set(binary);
846
+ binary = padded;
847
+ }
813
848
  const hdr = 0x7FF0;
814
849
  const hasHeader = String.fromCharCode(...binary.slice(hdr, hdr + 8)) === "TMR SEGA";
850
+ // Region nibble is PLATFORM-SPECIFIC and load-bearing: 4 = SMS export,
851
+ // 7 = GG international. A .gg ROM stamped with an SMS region (3/4) makes
852
+ // Genesis Plus GX (RetroArch/RetroDECK's SMS+GG core) boot it in "GG
853
+ // running SMS software" COMPATIBILITY mode — wrong video mode + wrong
854
+ // CRAM format for a native-GG program → black/garbled screen on the
855
+ // user's device while our BIOS-less host looked fine. Size nibble $C =
856
+ // 32KB checksum range ($0000-$7FEF).
857
+ const regionSize = args.platform === "gg" ? 0x7C : 0x4C;
815
858
  if (!hasHeader) {
816
859
  // No header emitted by the crt0 → write a complete TMR SEGA header
817
860
  // into the last 16 bytes of bank 0 ($7FF0-$7FFF). Without this the
818
861
  // export (US/EU) SMS BIOS shows "SOFTWARE ERROR" and refuses to run.
819
862
  // $7FF0-$7FF7 "TMR SEGA"; $7FF8-$7FF9 reserved ($00); $7FFA-$7FFB
820
863
  // checksum (filled below); $7FFC-$7FFE product code/version (zeros
821
- // ok for homebrew); $7FFF region+size = $4C (region 4 = export,
822
- // size $C = 32KB, the checksum range that covers $0000-$7FEF).
864
+ // ok for homebrew); $7FFF region+size (see regionSize above).
823
865
  const TMR = [0x54,0x4D,0x52,0x20,0x53,0x45,0x47,0x41]; // "TMR SEGA"
824
866
  for (let i = 0; i < 8; i++) binary[hdr + i] = TMR[i];
825
867
  binary[hdr + 8] = 0x00; binary[hdr + 9] = 0x00; // reserved
826
868
  binary[hdr + 12] = 0x00; binary[hdr + 13] = 0x00; // product code lo
827
869
  binary[hdr + 14] = 0x00; // product/version
828
- binary[hdr + 15] = 0x4C; // region 4 (export) + size $C (32KB)
829
870
  }
871
+ // Always stamp the platform-correct region/size — a crt0-provided header
872
+ // with an SMS region on a .gg build has the same compat-mode problem.
873
+ binary[hdr + 15] = regionSize;
830
874
  // Checksum = sum of bytes $0000..$7FEF (everything before the header),
831
- // stored little-endian at $7FFA. Region/size $4C declares the 32KB
832
- // range, so the BIOS checksums $0000-$7FEF.
875
+ // stored little-endian at $7FFA. Size nibble $C declares the 32KB
876
+ // range, so the BIOS checksums $0000-$7FEF. (The GG BIOS doesn't
877
+ // checksum, but writing it is harmless and correct.)
833
878
  let sum = 0;
834
879
  for (let i = 0; i < 0x7FF0; i++) sum = (sum + binary[i]) & 0xFFFF;
835
880
  binary[0x7FFA] = sum & 0xFF;
836
881
  binary[0x7FFB] = (sum >> 8) & 0xFF;
837
- r.log += `\n--- SMS header ${hasHeader ? "checksum fixed" : "written + checksummed"} ($7FFA=${sum.toString(16).toUpperCase().padStart(4,"0")}, region/size=$4C) ---`;
882
+ r.log += `\n--- ${args.platform.toUpperCase()} header ${hasHeader ? "checksum fixed" : "written + checksummed"} ($7FFA=${sum.toString(16).toUpperCase().padStart(4,"0")}, region/size=$${regionSize.toString(16).toUpperCase()}) ---`;
838
883
  }
839
884
  // MSX: the binary built with codeLoc=$4010 is a $4000-based page image.
840
885
  // SDCC/sdldz80 emit an ihx that, converted to bin, starts at the lowest