romdevtools 0.27.0 → 0.28.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 (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +309 -0
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +141 -24
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -37,10 +37,13 @@
37
37
  ;; ─── Reset vector at $0000 ────────────────────────────────────────
38
38
  .area _HEADER (ABS)
39
39
  .org 0x0000
40
+ ;; ONLY 8 BYTES fit before the RST $08 vector. The old block here
41
+ ;; (di/im 1/ld sp/jp = 9 bytes) overflowed into .org 0x0008, whose
42
+ ;; `ret` stomped the jp's high target byte -> boot jumped into
43
+ ;; garbage. di+im 1+jp = 6 bytes; SP setup moved to _boot below.
40
44
  di ; interrupts off until we're ready
41
45
  im 1 ; mode 1 — IRQs jump to $0038
42
- ld sp, #0xDFF0 ; GG/SMS stack (top of WRAM minus 16)
43
- jp gsinit ; skip the interrupt vector table
46
+ jp _boot ; continue past the vector table
44
47
 
45
48
  ;; ─── RST handlers (default = return) ──────────────────────────────
46
49
  .org 0x0008
@@ -80,6 +83,15 @@
80
83
  .org 0x0066
81
84
  retn
82
85
 
86
+ ;; ─── Boot continuation (right after the NMI vector) ───────────────
87
+ ;; SP first, then the C runtime init. Lives in the ABS header area so
88
+ ;; it exists at a known address regardless of where _CODE is linked
89
+ ;; (_CODE must start at >= $0100 so it can't overwrite this table).
90
+ .org 0x0068
91
+ _boot:
92
+ ld sp, #0xDFF0 ; stack at top of WRAM minus 16
93
+ jp gsinit
94
+
83
95
  ;; ─── crt0 body ────────────────────────────────────────────────────
84
96
  ;; Standard SDCC pattern: jump to a code area, run initializers, then
85
97
  ;; call main. The initializer area is filled by sdcc when it sees
@@ -103,21 +103,57 @@ static void sfx_flush_pending(void) {
103
103
  POKE(VOICE_BASE(i) + 1, 0x80); /* feedback off */
104
104
  POKE(VOICE_BASE(i) + 4, sfx_pending_period[i]);
105
105
  POKE(VOICE_BASE(i) + 5, 0x18); /* RELOAD + COUNT + 16us clock */
106
- POKE(VOICE_BASE(i) + 0, 64); /* volume */
106
+ POKE(VOICE_BASE(i) + 0, 100); /* volume (was 64 — read as near-silent on hardware) */
107
107
  } else if (sfx_pending_kind[i] == 2) {
108
108
  /* Noise on voice 3. */
109
109
  POKE(VOICE_BASE(i) + 7, 0x01); /* 12-bit LFSR */
110
110
  POKE(VOICE_BASE(i) + 1, 0x95); /* classic noise feedback */
111
111
  POKE(VOICE_BASE(i) + 4, 40);
112
112
  POKE(VOICE_BASE(i) + 5, 0x18);
113
- POKE(VOICE_BASE(i) + 0, 64);
113
+ POKE(VOICE_BASE(i) + 0, 100);
114
114
  }
115
115
  sfx_pending_kind[i] = 0;
116
116
  }
117
117
  }
118
118
 
119
+ /* ── background music: 16-step melody loop on voice 1 ───────────────
120
+ * Ticked from sfx_update() through the SAME staged-write path (R57),
121
+ * so every scaffold that already calls sfx_init() + sfx_update() gets
122
+ * continuous music for free — "no sound at all" was the Lynx playtest
123
+ * verdict. sfx_music(0) turns it off. SFX use voice 0 (+ noise on 3).
124
+ * MIKEY period at the 16us clock: freq ~= 31250 / period. */
125
+ static const uint8_t music_period[16] = {
126
+ 119, 95, 80, 60, 80, 95, 119, 0, /* C4 E4 G4 C5 G4 E4 C4 - */
127
+ 142, 119, 95, 71, 95, 119, 142, 0, /* A3 C4 E4 A4 E4 C4 A3 - */
128
+ };
129
+ static uint8_t music_enabled = 1;
130
+ static uint8_t music_step, music_timer;
131
+
132
+ void sfx_music(uint8_t on) {
133
+ music_enabled = on;
134
+ music_step = 0;
135
+ music_timer = 0;
136
+ }
137
+
138
+ static void music_tick(void) {
139
+ uint8_t p;
140
+ if (!music_enabled) return;
141
+ if (music_timer == 0) {
142
+ p = music_period[music_step & 15];
143
+ if (p) {
144
+ sfx_pending_kind[1] = 1; /* staged like any tone (R57-safe) */
145
+ sfx_pending_period[1] = p;
146
+ sfx_remaining[1] = 8; /* hold 8 of 9 frames — articulated */
147
+ }
148
+ music_step++;
149
+ }
150
+ music_timer++;
151
+ if (music_timer >= 9) music_timer = 0;
152
+ }
153
+
119
154
  void sfx_update(void) {
120
155
  uint8_t i;
156
+ music_tick();
121
157
  /* R57: flush any sfx_tone/sfx_noise requests from THIS frame. The
122
158
  * caller is expected to have just returned from tgi_updatedisplay
123
159
  * (or wait_vblank), so the synchronous timer-event sweep that handy
@@ -54,6 +54,7 @@ void sfx_init(void);
54
54
  void sfx_tone(uint8_t channel, uint8_t period, uint8_t length_frames);
55
55
  void sfx_noise(uint8_t length_frames);
56
56
  void sfx_update(void);
57
+ void sfx_music(uint8_t on); /* background melody on voice 1 — ON by default; 0 = off */
57
58
  void sfx_off(void);
58
59
 
59
60
  #endif
@@ -117,6 +117,12 @@ exactly this.
117
117
  generator + the envelope (period + shape bits).
118
118
  - `memory({op:'read'})` regions: `msx_vram`, `msx_vdp_regs`, `msx_vdp_status`,
119
119
  `msx_palette`, `msx_cpu_regs`, `msx_psg_regs`, plus `system_ram` (work RAM).
120
+ - `disasm({target:'rom'|'references'|'project'})` — native binutils z80
121
+ `objdump`. MegaROMs (>32 KB) are handled per 16 KB bank: `references` scans
122
+ bank 0 at `$4000` (after the "AB" header) and banks 1+ at `$8000` (an
123
+ assumed ASCII16-style window), refs tagged `romBank`;
124
+ `disasm({target:'project'})` splits the header into its own data region and
125
+ emits a bank-by-bank native rebuild recipe in `BUILD.md`.
120
126
 
121
127
  ## MCP debug & inspection tooling
122
128
 
@@ -66,3 +66,24 @@ fixed hardware colors — you choose indices, not RGB.
66
66
  The build worker pool can transiently fail. Re-run the build. If it fails
67
67
  consistently, read the `log` — SDCC's C89 parser errors are terse; common causes
68
68
  are `//` comments, mid-block declarations, or file-scope inline asm (see above).
69
+
70
+
71
+ ## PSG writes get eaten — sound code "runs" but the chip stays silent
72
+
73
+ The BIOS KEYINT interrupt fires every frame and reads PSG register 14 (the
74
+ joystick row) — and it CLOBBERS the PSGADDR latch. If an interrupt lands
75
+ between your `PSGADDR = n` and the matching `PSGWRITE`, your byte goes into
76
+ R14 instead of the register you selected. Symptom: the mixer looks right but
77
+ periods/volumes stay 0 — total silence even though your code clearly ran.
78
+
79
+ **Rule: wrap every PSGADDR/PSGWRITE sequence in `__asm__("di")` /
80
+ `__asm__("ei")`.** The bundled `msx_psg_tone`/`msx_psg_off` (and the music
81
+ ticker) already do this; copy the pattern for any direct PSG access you write.
82
+
83
+ ## A `static x = 5;` boots as 0 (historical — fixed in the bundled crt0)
84
+
85
+ The old `msx_crt0.s` placed the SDCC `_INITIALIZER` area in RAM, so the boot
86
+ copy duplicated uninitialised RAM onto itself: every value-initialised static
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.
@@ -27,6 +27,8 @@
27
27
  .globl _main
28
28
  .globl l__INITIALIZER
29
29
  .globl s__INITIALIZER
30
+ .globl s__DATA
31
+ .globl l__DATA
30
32
  .globl s__INITIALIZED
31
33
 
32
34
  ;; ─── Cartridge ROM header at $4000 ────────────────────────────────
@@ -44,7 +46,15 @@
44
46
  ;; ─── crt0 body ────────────────────────────────────────────────────
45
47
  ;; Standard SDCC area order so the linker fills _GSINIT with the global
46
48
  ;; initializer fragments sdcc emits, then _GSFINAL.
49
+ ;; AREA ORDERING IS LOAD-BEARING (same bug class fixed in the SMS/GG
50
+ ;; crt0s 2026-06-08): `_INITIALIZER` (the ROM image of every value-
51
+ ;; initialised static) MUST be declared in the ROM group — otherwise
52
+ ;; sdld places it in RAM after `_INITIALIZED` and the init copy below
53
+ ;; copies uninitialised RAM onto itself, so every `static x = N;`
54
+ ;; boots as 0. On MSX that silenced ALL scaffold audio (the PSG
55
+ ;; music/sfx state booted zeroed) among other ghosts.
47
56
  .area _HOME
57
+ .area _INITIALIZER
48
58
  .area _CODE
49
59
  .area _GSINIT
50
60
  .area _GSFINAL
@@ -59,6 +69,23 @@
59
69
 
60
70
  ;; INIT entry — the BIOS CALLs here with interrupts on and a valid stack.
61
71
  init:
72
+ ;; ── Zero the BSS segment (`_DATA`) ── every uninitialised static
73
+ ;; must read back 0 at boot (power-on RAM is garbage).
74
+ ld bc, #l__DATA
75
+ ld a, b
76
+ or a, c
77
+ jr Z, bss_done
78
+ ld hl, #s__DATA
79
+ ld (hl), #0x00
80
+ ld d, h
81
+ ld e, l
82
+ inc de
83
+ dec bc
84
+ ld a, b
85
+ or a, c
86
+ jr Z, bss_done
87
+ ldir
88
+ bss_done:
62
89
  ;; Copy initialized-data image from ROM to RAM (SDCC global inits).
63
90
  ld bc, #l__INITIALIZER
64
91
  ld a, b
@@ -87,6 +87,8 @@ 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_music(uint8_t on); /* background melody on channel C — ON by default; 0 = off */
91
+ void msx_music_tick(void); /* call once per frame (scaffolds do) */
90
92
  void msx_psg_off(uint8_t chan);
91
93
 
92
94
  #endif /* MSX_HW_H */
@@ -121,6 +121,13 @@ void msx_psg_tone(uint8_t chan, uint16_t period, uint8_t vol) {
121
121
  uint8_t fine = (uint8_t)(period & 0xFF);
122
122
  uint8_t coarse = (uint8_t)((period >> 8) & 0x0F);
123
123
 
124
+ /* DI around the whole register sequence: the BIOS KEYINT ISR reads
125
+ * PSG R14 (joystick row) every frame, and it CLOBBERS the PSGADDR
126
+ * latch — an IRQ between our PSGADDR and PSGWRITE sent the period/
127
+ * volume bytes into R14 instead. Symptom: mixer set, period 0,
128
+ * amplitude 0 → every MSX scaffold was silent. */
129
+ __asm__("di");
130
+
124
131
  /* tone period: regs 0/1 (A), 2/3 (B), 4/5 (C) */
125
132
  PSGADDR = (uint8_t)(chan << 1); PSGWRITE = fine;
126
133
  PSGADDR = (uint8_t)((chan << 1) + 1); PSGWRITE = coarse;
@@ -136,15 +143,53 @@ void msx_psg_tone(uint8_t chan, uint16_t period, uint8_t vol) {
136
143
  mixer &= (uint8_t)~(1 << chan); /* tone ON for this channel */
137
144
  PSGADDR = 7;
138
145
  PSGWRITE = mixer;
146
+ __asm__("ei");
139
147
  }
140
148
 
141
149
  /* Silence a PSG channel: zero its volume and re-disable its tone bit. */
142
150
  void msx_psg_off(uint8_t chan) {
143
151
  uint8_t mixer;
152
+ __asm__("di"); /* same KEYINT race as above */
144
153
  PSGADDR = (uint8_t)(8 + chan); PSGWRITE = 0; /* volume 0 */
145
154
  PSGADDR = 7;
146
155
  mixer = PSGREAD;
147
156
  mixer |= (uint8_t)(1 << chan); /* tone OFF for this channel */
148
157
  PSGADDR = 7;
149
158
  PSGWRITE = mixer;
159
+ __asm__("ei");
160
+ }
161
+
162
+ /* ── background music: 16-step melody loop on PSG channel C (2) ─────
163
+ * Call msx_music_tick() once per frame (the scaffolds wire it in after
164
+ * their vsync wait); msx_music(0) turns it off. SFX use channels A/B,
165
+ * so effects always cut through. AY period = 1789773 / (16 * freq). */
166
+ static const uint16_t _msx_music_per[16] = {
167
+ 427, 339, 285, 214, 285, 339, 427, 0, /* C4 E4 G4 C5 G4 E4 C4 - */
168
+ 508, 427, 339, 254, 339, 427, 508, 0, /* A3 C4 E4 A4 E4 C4 A3 - */
169
+ };
170
+ static uint8_t _msx_music_on = 1;
171
+ static uint8_t _msx_music_step;
172
+ static uint8_t _msx_music_timer;
173
+
174
+ void msx_music(uint8_t on) {
175
+ _msx_music_on = on;
176
+ _msx_music_step = 0;
177
+ _msx_music_timer = 0;
178
+ if (!on) msx_psg_off(2);
179
+ }
180
+
181
+ void msx_music_tick(void) {
182
+ uint16_t p;
183
+ if (!_msx_music_on) return;
184
+ if (_msx_music_timer == 0) {
185
+ p = _msx_music_per[_msx_music_step & 15];
186
+ if (p) {
187
+ msx_psg_tone(2, p, 12); /* AY volume is ~logarithmic — 9 was a whisper */
188
+ } else {
189
+ msx_psg_off(2); /* rest */
190
+ }
191
+ ++_msx_music_step;
192
+ }
193
+ ++_msx_music_timer;
194
+ if (_msx_music_timer >= 9) _msx_music_timer = 0;
150
195
  }
@@ -393,7 +393,11 @@ build({ output:'rom', platform:'nes',
393
393
  inesHeader:{ prgBanks:2, chrBanks:1, mapper:0, mirroring:"vertical" } })
394
394
  ```
395
395
  Mutually exclusive with `linkerConfig`. Works for any NROM (mapper 0, ≤2 PRG
396
- banks); for a banked mapper supply a linker `.cfg` that places each bank.
396
+ banks). For a BANKED mapper you don't hand-write the glue anymore:
397
+ `disasm({target:'project'})` emits a HEADER segment (the original 16 iNES
398
+ bytes), a `.segment "PRGn"` wrapper per bank, and a multi-bank `nes_rebuild.cfg`
399
+ (switchable banks at $8000, fixed top bank at $C000), all wired into
400
+ `rebuild.json` via `linkerConfigPath` — a one-call byte-exact rebuild.
397
401
 
398
402
  **2. `linkerConfig:"chr-rom"` — for homebrew C that ships FIXED tile art.**
399
403
  A cc65-C preset (segment split + a CHARS segment in an 8 KB ROM2 bank). Put your
@@ -402,8 +406,11 @@ tiles in `.segment "CHARS"` (`.incbin "tiles.chr"`) + pass the blob via
402
406
  other bank configs, prefer `inesHeader`.
403
407
 
404
408
  **3. `disasm({target:'project'})` — disassemble → rebuild, in two calls.**
405
- For NES it now extracts the CHR-ROM to `chr.bin`, writes a `rebuild.json` (the
406
- exact `build({inesHeader})` call, with absolute paths) and a `BUILD.md`. Feed
409
+ For NES it extracts the CHR-ROM to `chr.bin`, writes a `rebuild.json` (the
410
+ exact `build({...})` call, with absolute paths) and a `BUILD.md`. NROM gets the
411
+ `inesHeader` one-call form; BANKED mappers (UxROM/MMC1/MMC3…) get per-bank
412
+ `PRGn` segment wrappers + the original-bytes HEADER segment + a generated
413
+ multi-bank `.cfg` referenced via `linkerConfigPath`. Either way: feed
407
414
  `rebuild.json` straight back to `build` and you get a byte-identical ROM. This
408
415
  is the RE workhorse loop: `disasm({target:'project'})` → edit the `.asm` →
409
416
  rebuild → `diffRoms` to confirm your patch landed.
@@ -330,6 +330,47 @@ void sound_init(void) {
330
330
  APUFRAMECTR = 0x40; /* 4-step frame counter, disable frame IRQ */
331
331
  }
332
332
 
333
+ /* ── background music: 16-step melody on the TRIANGLE channel ───────
334
+ * The pulse channels + noise stay free for sound_play_tone/noise SFX.
335
+ * Call sound_music_tick() once per frame (after ppu_wait_nmi); the
336
+ * scaffolds wire it in. sound_music(0) silences it. ("No sound" was
337
+ * the dominant NES playtest complaint — a rare 6-frame blip isn't
338
+ * enough; continuous triangle gives every scaffold a musical floor.)
339
+ * NTSC triangle period for note f ≈ 1789773/(32*f) - 1. */
340
+ static const uint16_t music_period_tbl[16] = {
341
+ 213, 168, 141, 106, 141, 168, 213, 0, /* C4 E4 G4 C5 G4 E4 C4 - */
342
+ 253, 213, 168, 126, 168, 213, 253, 0, /* A3 C4 E4 A4 E4 C4 A3 - */
343
+ };
344
+ static uint8_t music_enabled = 1;
345
+ static uint8_t music_step;
346
+ static uint8_t music_timer;
347
+
348
+ void sound_music(uint8_t on) {
349
+ music_enabled = on;
350
+ music_step = 0;
351
+ music_timer = 0;
352
+ if (!on) { TRI_LINEAR = 0x00; TRI_HI = 0x08; } /* reload 0 linear → silent */
353
+ }
354
+
355
+ void sound_music_tick(void) {
356
+ uint16_t p;
357
+ if (!music_enabled) return;
358
+ if (music_timer == 0) {
359
+ p = music_period_tbl[music_step & 15];
360
+ if (p) {
361
+ TRI_LINEAR = 0xFF; /* halt flag + max linear: sustain */
362
+ TRI_LO = (uint8_t)(p & 0xFF);
363
+ TRI_HI = (uint8_t)((p >> 8) & 0x07); /* also reloads the linear counter */
364
+ } else {
365
+ TRI_LINEAR = 0x00; /* rest */
366
+ TRI_HI = 0x08;
367
+ }
368
+ ++music_step;
369
+ }
370
+ ++music_timer;
371
+ if (music_timer >= 9) music_timer = 0;
372
+ }
373
+
333
374
  void sound_play_tone(uint8_t channel, uint16_t period, uint8_t vol_4bit, uint8_t length_frames) {
334
375
  uint8_t len5 = length_frames & 0x1F;
335
376
  uint8_t v = vol_4bit & 0x0F;
@@ -145,6 +145,8 @@ void sound_init(void);
145
145
  void sound_play_tone(uint8_t channel, uint16_t period, uint8_t vol_4bit, uint8_t length_frames);
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
+ 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) */
148
150
 
149
151
  /* ── Globals ──────────────────────────────────────────────────── */
150
152
  extern uint8_t shadow_oam[256]; /* at $0200, DMA'd by NMI */
@@ -101,3 +101,12 @@ screen. Keep at least one (2+ byte) global. See TROUBLESHOOTING.md.
101
101
  + LFO.
102
102
  - `memory({op:'read'})` regions: `pce_vdc_vram`, `pce_vdc_satb`, `pce_vdc_regs`,
103
103
  `pce_vce_palette`, `pce_cpu_regs`, `pce_psg_regs`.
104
+ - `disasm({target:'rom'|'references'|'project'})` — da65's native `huc6280`
105
+ CPU mode. HuCards >32 KB are handled per 8 KB page (page 0 at `$E000`,
106
+ where MPR7 maps it at reset — the vectors live there; pages 1+ at `$8000`,
107
+ an assumed window since the game's MPR writes decide at runtime).
108
+ `references` tags refs with `romBank`; `disasm({target:'project'})` emits
109
+ per-page regions + segment wrappers + a generated `.cfg`, and — because the
110
+ PCE asm toolchain IS cc65/ca65 — a **one-call byte-identical `build()`
111
+ rebuild** via `rebuild.json` (flat and banked; a 512-byte copier header is
112
+ split out and re-emitted as a HEADER segment).
@@ -58,3 +58,12 @@ you likely hit a BRK or an unhandled IRQ — check that you didn't enable a VDC
58
58
  `clrscr()` must run before `cputs()` (it inits the font + VDC). Also confirm
59
59
  `.bss` is non-empty (see the first entry) — a broken crt0 means conio's font
60
60
  upload never happened.
61
+
62
+
63
+ ## PSG tone plays but is nearly inaudible
64
+
65
+ The 5-bit channel volume (`PSG_CHAN_CTRL` low bits, 0-31) is roughly an
66
+ ATTENUATOR: each step below 31 costs ~1.5 dB. A "middle" value like 13 is
67
+ about -27 dB — effectively silence on real hardware and most cores. Use
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.)
@@ -126,6 +126,7 @@ u8 pce_joy_read(void); /* read pad 1 -> clean bitmask (PCE_JOY_* below) */
126
126
 
127
127
  /* ---- sound helpers (pce_sound.c) ----------------------------------------- */
128
128
  void psg_tone(u8 chan, u16 freq, u8 vol); /* play a wavetable tone (vol 0..31) */
129
- void psg_off(u8 chan); /* silence + disable a channel */
129
+ void psg_off(u8 chan);
130
+ void psg_music_tick(void); /* call once per frame (scaffolds do) */ /* silence + disable a channel */
130
131
 
131
132
  #endif /* PCE_HW_H */
@@ -51,3 +51,25 @@ void psg_off(u8 chan) {
51
51
  PSG_CHAN_SELECT = chan;
52
52
  PSG_CHAN_CTRL = 0x00; /* bit7 clear = channel off, volume 0 */
53
53
  }
54
+
55
+ /* ── background music: 8-step melody loop on channel 5 ─────────────
56
+ * Call psg_music_tick() once per frame (the scaffolds wire it in after
57
+ * their vsync wait). Deliberately MINIMAL — the PCE boot bank is 8KB
58
+ * and the puzzle scaffold sits within ~100 bytes of the ceiling, so
59
+ * there's no on/off toggle and no rests (re-trigger every note).
60
+ * SFX use channels 0-3; the melody never fights an effect.
61
+ * PCE freq is a DIVIDER: pitch ~= 3.58MHz / (32 * freq). */
62
+ static const u16 _music_div[8] = {
63
+ 427, 339, 285, 214, 508, 427, 339, 254, /* C4 E4 G4 C5 A3 C4 E4 A4 */
64
+ };
65
+ static u8 _music_step;
66
+ static u8 _music_timer;
67
+
68
+ void psg_music_tick(void) {
69
+ if (_music_timer == 0) {
70
+ psg_tone(5, _music_div[_music_step & 7], 29); /* PCE vol is ~-1.5dB/step from 31 — 13 was -27dB, inaudible */
71
+ ++_music_step;
72
+ }
73
+ ++_music_timer;
74
+ if (_music_timer >= 9) _music_timer = 0;
75
+ }
@@ -318,6 +318,11 @@ CB/ED/DD/FD/DDCB/FDCB — and feeds the same auto-label, register-annotation,
318
318
  file-offset, and `untilReturn` pipeline used by the NES and SNES
319
319
  disassemblers.
320
320
 
321
+ Sega-mapper banked carts (>48 KB) are handled per-bank: `references` scans
322
+ every 16 KB bank (bank 0 @ `$0000`, bank 1 @ `$4000`, banks 2+ @ their slot-2
323
+ window `$8000`), refs tagged `romBank`; `disasm({target:'project'})` emits one
324
+ region per bank with a bank-by-bank native rebuild recipe in `BUILD.md`.
325
+
321
326
  ## Horizontal scrolling (for side-scrollers)
322
327
 
323
328
  The `platformer` scaffold is single-screen. To make it a side-scroller:
@@ -118,6 +118,12 @@ If you ported an SMS ROM straight to `.gg` it'll boot and run, but
118
118
  the colours will be very dark (2-bit values reinterpreted as 4-bit)
119
119
  and the visible area is in the top-left corner.
120
120
 
121
+ Also check the header region byte at `$7FFF` (high nibble = region,
122
+ low nibble = size): romdev stamps `$7C` (GG international) on `.gg`
123
+ builds, but an SMS nibble there (`$4x`) makes gpgx boot the file in
124
+ SMS compatibility mode — wrong resolution and palette depth no matter
125
+ what your code does.
126
+
121
127
  ## "ROM > 32 KB doesn't run"
122
128
 
123
129
  The default template is single-bank (32 KB). To use the Sega
@@ -28,10 +28,13 @@
28
28
  ;; ─── Reset vector at $0000 ────────────────────────────────────────
29
29
  .area _HEADER (ABS)
30
30
  .org 0x0000
31
+ ;; ONLY 8 BYTES fit before the RST $08 vector. The old block here
32
+ ;; (di/im 1/ld sp/jp = 9 bytes) overflowed into .org 0x0008, whose
33
+ ;; `ret` stomped the jp's high target byte -> boot jumped into
34
+ ;; garbage. di+im 1+jp = 6 bytes; SP setup moved to _boot below.
31
35
  di ; interrupts off until we're ready
32
36
  im 1 ; mode 1 — IRQs jump to $0038
33
- ld sp, #0xDFF0 ; SMS stack (top of WRAM minus 16)
34
- jp gsinit ; skip the interrupt vector table
37
+ jp _boot ; continue past the vector table
35
38
 
36
39
  ;; ─── RST handlers (default = return) ──────────────────────────────
37
40
  .org 0x0008
@@ -69,6 +72,15 @@
69
72
  .org 0x0066
70
73
  retn
71
74
 
75
+ ;; ─── Boot continuation (right after the NMI vector) ───────────────
76
+ ;; SP first, then the C runtime init. Lives in the ABS header area so
77
+ ;; it exists at a known address regardless of where _CODE is linked
78
+ ;; (_CODE must start at >= $0100 so it can't overwrite this table).
79
+ .org 0x0068
80
+ _boot:
81
+ ld sp, #0xDFF0 ; stack at top of WRAM minus 16
82
+ jp gsinit
83
+
72
84
  ;; ─── crt0 body ────────────────────────────────────────────────────
73
85
  ;; Standard SDCC pattern: jump to a code area, run initializers, then
74
86
  ;; call main. The initializer area is filled by sdcc when it sees
@@ -222,6 +222,11 @@ video are fully readable, so you assert live state instead of guessing:
222
222
  cpu:'spc700'})` for the sound CPU.
223
223
  - **Audio:** the S-DSP is fully decodable — full per-voice state plus the
224
224
  master mixer (see "Debugging sound" above for `audioDebug`).
225
+ - **`disasm({target:'references'})`** scans EVERY 32 KB LoROM bank (refs
226
+ tagged `romBank`) — a hit in bank 12 of a 1 MB cart shows up, not just
227
+ bank 0. `disasm({target:'project'})` likewise splits per-bank with a
228
+ native ca65/ld65 rebuild recipe (build() is asar, which can't consume
229
+ the disasm's ca65 output).
225
230
  - **Memory regions:** `memory({op:'read'})` exposes OAM, CGRAM, ARAM (SPC700
226
231
  audio RAM), and **FillRAM**. Note the FillRAM quirk: snes9x mirrors the
227
232
  PPU registers $2100-$213F (OBSEL/BGMODE/TM/TS/color-math, etc.) into
@@ -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