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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +309 -0
- 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 +141 -24
- 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
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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)
|
|
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
|