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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +322 -3
- package/README.md +1 -1
- package/examples/README.md +1 -1
- package/examples/atari2600/templates/platformer.asm +18 -9
- package/examples/atari2600/templates/racing.asm +25 -4
- package/examples/atari2600/templates/shmup.asm +30 -5
- package/examples/atari2600/templates/sports.asm +41 -9
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +12 -8
- package/examples/atari7800/templates/puzzle.c +7 -4
- package/examples/atari7800/templates/racing.c +5 -2
- package/examples/atari7800/templates/shmup.c +8 -4
- package/examples/atari7800/templates/sports.c +6 -3
- package/examples/c64/templates/platformer.c +28 -24
- package/examples/c64/templates/puzzle.c +77 -16
- package/examples/c64/templates/racing.c +9 -0
- package/examples/c64/templates/shmup.c +13 -1
- package/examples/c64/templates/sports.c +9 -4
- package/examples/gb/templates/platformer.c +6 -2
- package/examples/gb/templates/puzzle.c +279 -101
- package/examples/gb/templates/racing.c +13 -1
- package/examples/gb/templates/shmup.c +13 -1
- package/examples/gb/templates/sports.c +9 -3
- package/examples/gba/templates/platformer.c +7 -13
- package/examples/gba/templates/puzzle.c +93 -15
- package/examples/gba/templates/racing.c +13 -1
- package/examples/gba/templates/shmup.c +13 -1
- package/examples/gba/templates/sports.c +17 -5
- package/examples/gbc/templates/platformer.c +6 -2
- package/examples/gbc/templates/puzzle.c +878 -178
- package/examples/gbc/templates/racing.c +13 -1
- package/examples/gbc/templates/shmup.c +13 -1
- package/examples/gbc/templates/sports.c +9 -3
- package/examples/genesis/templates/puzzle.c +76 -15
- package/examples/genesis/templates/racing.c +13 -1
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/gg/templates/platformer.c +4 -0
- package/examples/gg/templates/puzzle.c +80 -14
- package/examples/gg/templates/racing.c +17 -1
- package/examples/gg/templates/shmup.c +17 -1
- package/examples/gg/templates/sports.c +4 -0
- package/examples/lynx/templates/platformer.c +25 -6
- package/examples/lynx/templates/puzzle.c +77 -14
- package/examples/lynx/templates/shmup.c +13 -1
- package/examples/lynx/templates/sports.c +5 -2
- package/examples/msx/platformer/main.c +2 -0
- package/examples/msx/puzzle/main.c +78 -15
- package/examples/msx/racing/main.c +1 -0
- package/examples/msx/shmup/main.c +1 -0
- package/examples/msx/sports/main.c +3 -2
- package/examples/nes/templates/platformer.c +11 -3
- package/examples/nes/templates/puzzle.c +81 -21
- package/examples/nes/templates/racing.c +15 -1
- package/examples/nes/templates/shmup.c +1 -0
- package/examples/nes/templates/sports.c +1 -0
- package/examples/pce/platformer/main.c +3 -1
- package/examples/pce/puzzle/main.c +78 -12
- package/examples/pce/racing/main.c +1 -0
- package/examples/pce/shmup/main.c +5 -4
- package/examples/pce/sports/main.c +4 -3
- package/examples/sms/templates/platformer.c +4 -0
- package/examples/sms/templates/puzzle.c +80 -14
- package/examples/sms/templates/racing.c +17 -1
- package/examples/sms/templates/shmup.c +17 -1
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +4 -0
- package/examples/snes/templates/platformer.c +32 -15
- package/examples/snes/templates/puzzle.c +84 -16
- package/examples/snes/templates/racing.c +20 -1
- package/examples/snes/templates/shmup.c +20 -2
- package/examples/snes/templates/sports.c +7 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +245 -10
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/disasm-rebuild.js +315 -65
- package/src/mcp/tools/disasm.js +149 -28
- package/src/mcp/tools/find-references.js +216 -51
- package/src/mcp/tools/frame.js +11 -4
- package/src/mcp/tools/index.js +15 -1
- package/src/mcp/tools/input.js +26 -3
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/project.js +35 -9
- package/src/mcp/tools/toolchain.js +43 -10
- package/src/mcp/tools/watch-memory.js +172 -25
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
- package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
- package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
- package/src/platforms/gb/MENTAL_MODEL.md +16 -1
- package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
- package/src/platforms/gb/lib/c/patch-header.js +7 -4
- package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/patch-header.js +7 -4
- package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
- package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
- package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
- package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
- package/src/platforms/msx/lib/c/msx_hw.h +2 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
- package/src/platforms/nes/MENTAL_MODEL.md +10 -3
- package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
- package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
- package/src/platforms/pce/MENTAL_MODEL.md +9 -0
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +2 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/sms/MENTAL_MODEL.md +5 -0
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
- package/src/platforms/snes/MENTAL_MODEL.md +5 -0
- package/src/playtest/playtest.js +73 -3
- 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)
|
|
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
|
|
406
|
-
exact `build({
|
|
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);
|
|
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
|
-
|
|
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
|
package/src/playtest/playtest.js
CHANGED
|
@@ -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
|
|
605
|
-
//
|
|
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
|
-
|
|
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
|
package/src/toolchains/index.js
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
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
|
|
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.
|
|
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---
|
|
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
|