romdevtools 0.26.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 +322 -3
  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 +172 -25
  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
@@ -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
@@ -727,7 +727,17 @@ 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
@@ -809,32 +819,51 @@ export async function buildForPlatform(args) {
809
819
  // rejected it. Checksum = sum of bytes $0000..$7FEF (everything before
810
820
  // the header), stored little-endian. GG BIOS doesn't check, but writing
811
821
  // 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) {
822
+ if (binary && r.exitCode === 0 && (args.platform === "sms" || args.platform === "gg")) {
823
+ // Pad to a full 32KB bank FIRST. sdld emits up to the highest used
824
+ // address, so a small program can come out under $8000 — which (a)
825
+ // skipped this whole header block before (the header guard required
826
+ // 32KB) and (b) odd-size ROMs misbehave on real mappers/flashcarts.
827
+ if (binary.length < 0x8000) {
828
+ const padded = new Uint8Array(0x8000);
829
+ padded.set(binary);
830
+ binary = padded;
831
+ }
813
832
  const hdr = 0x7FF0;
814
833
  const hasHeader = String.fromCharCode(...binary.slice(hdr, hdr + 8)) === "TMR SEGA";
834
+ // Region nibble is PLATFORM-SPECIFIC and load-bearing: 4 = SMS export,
835
+ // 7 = GG international. A .gg ROM stamped with an SMS region (3/4) makes
836
+ // Genesis Plus GX (RetroArch/RetroDECK's SMS+GG core) boot it in "GG
837
+ // running SMS software" COMPATIBILITY mode — wrong video mode + wrong
838
+ // CRAM format for a native-GG program → black/garbled screen on the
839
+ // user's device while our BIOS-less host looked fine. Size nibble $C =
840
+ // 32KB checksum range ($0000-$7FEF).
841
+ const regionSize = args.platform === "gg" ? 0x7C : 0x4C;
815
842
  if (!hasHeader) {
816
843
  // No header emitted by the crt0 → write a complete TMR SEGA header
817
844
  // into the last 16 bytes of bank 0 ($7FF0-$7FFF). Without this the
818
845
  // export (US/EU) SMS BIOS shows "SOFTWARE ERROR" and refuses to run.
819
846
  // $7FF0-$7FF7 "TMR SEGA"; $7FF8-$7FF9 reserved ($00); $7FFA-$7FFB
820
847
  // 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).
848
+ // ok for homebrew); $7FFF region+size (see regionSize above).
823
849
  const TMR = [0x54,0x4D,0x52,0x20,0x53,0x45,0x47,0x41]; // "TMR SEGA"
824
850
  for (let i = 0; i < 8; i++) binary[hdr + i] = TMR[i];
825
851
  binary[hdr + 8] = 0x00; binary[hdr + 9] = 0x00; // reserved
826
852
  binary[hdr + 12] = 0x00; binary[hdr + 13] = 0x00; // product code lo
827
853
  binary[hdr + 14] = 0x00; // product/version
828
- binary[hdr + 15] = 0x4C; // region 4 (export) + size $C (32KB)
829
854
  }
855
+ // Always stamp the platform-correct region/size — a crt0-provided header
856
+ // with an SMS region on a .gg build has the same compat-mode problem.
857
+ binary[hdr + 15] = regionSize;
830
858
  // 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.
859
+ // stored little-endian at $7FFA. Size nibble $C declares the 32KB
860
+ // range, so the BIOS checksums $0000-$7FEF. (The GG BIOS doesn't
861
+ // checksum, but writing it is harmless and correct.)
833
862
  let sum = 0;
834
863
  for (let i = 0; i < 0x7FF0; i++) sum = (sum + binary[i]) & 0xFFFF;
835
864
  binary[0x7FFA] = sum & 0xFF;
836
865
  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) ---`;
866
+ 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
867
  }
839
868
  // MSX: the binary built with codeLoc=$4010 is a $4000-based page image.
840
869
  // SDCC/sdldz80 emit an ihx that, converted to bin, starts at the lowest