diff --git a/recompiler/src/code_generator.c b/recompiler/src/code_generator.c index e14a71b..c34a69c 100644 --- a/recompiler/src/code_generator.c +++ b/recompiler/src/code_generator.c @@ -2041,6 +2041,63 @@ static bool return_adjust_func_matches(const GameConfig *cfg, uint16_t target) { return false; } +/* JSR targets whose callee consumes the pushed return address as data + * (e.g. inline bytecode trampolines that PLA it into a pointer). The + * pushed 3-byte frame is gone when the callee returns, so the post-call + * S check must restore S instead of bailing. */ +static bool absorb_jsr_ret_matches(const GameConfig *cfg, uint16_t target) { + for (int i = 0; i < cfg->absorb_jsr_ret_count; i++) { + if (target == cfg->absorb_jsr_rets[i]) + return true; + } + return false; +} + +/* Is `addr` a genuine instruction START? Decodes forward from `base` (a + * known instruction boundary, e.g. the function's first byte) and reports + * whether the linear stream lands exactly on `addr` before reaching `limit`. + * Guards pattern-matchers that read single bytes (e.g. "previous byte == + * 0x48 means PHA") against matching an OPERAND — e.g. Tetris $9CA2 `INC $48` + * has operand $48 immediately before a plain RTS ($9CA4), which the 2-PHA + * RTS-as-JMP detector mistook for PHA and turned the RTS into a stack-pop + * dispatch, corrupting S and jumping to $80C1/$3501. A misaligned decode + * (data tables, branch targets) yields false negatives only — those degrade + * to a plain RTS, which is always safe. */ +static bool is_insn_boundary_from(const NESRom *rom, int bank, uint16_t base, + uint16_t addr, uint16_t limit) { + if (addr < base || addr >= limit) return false; + uint16_t a = base; + while (a < limit) { + if (a == addr) return true; + uint8_t op = rom_read(rom, bank, a); + int sz = g_opcode_table[op].size; + if (sz <= 0 || sz > 3) return false; + uint16_t next = (uint16_t)(a + sz); + if (next <= a) return false; + a = next; + } + return false; +} + +/* JSR targets whose callee leaves the pushed return address on the 6502 + * stack (its RTS is a plain C return that never touches S). Same post-call + * S restore as absorb_jsr_ret, but no site+5 goto — execution continues at + * site+3 (the ordinary 3-byte JSR fall-through). */ +static bool restore_jsr_matches(const GameConfig *cfg, uint16_t target) { + for (int i = 0; i < cfg->restore_jsr_count; i++) { + if (target == cfg->restore_jsrs[i]) + return true; + } + return false; +} + +/* Inline-bytecode trampoline JSR sites (absorb_jsr_ret): the callee pops the + * JSR return as its script pointer and consumes the 2 ROM bytes after the + * JSR operand as inline script data, so on real hardware the interpreter's + * exit RTS resumes at site+5, not the ordinary fall-through site+3. The + * emitter handles this by returning 5 from emit_instruction so the next + * label lands at site+5 (see the MN_JSR absorb_jsr_ret case). */ + #define DUMMY_PUSH_PAIR \ "g_ram[0x100+g_cpu.S]=0; g_cpu.S--; g_ram[0x100+g_cpu.S]=0; g_cpu.S--; " #define DUMMY_JSR_PRE \ @@ -2948,6 +3005,30 @@ static int emit_instruction(FILE *f, const NESRom *rom, int bank, if (is_yield_func(abs16)) { /* Yield function: close the brace but skip the bail check */ fprintf(f, "}\n"); + } else if (restore_jsr_matches(cfg, abs16)) { + /* Callee leaves the pushed frame on the 6502 stack (its + * RTS is a plain C return): restore S, no bail, and fall + * through to site+3 like a normal 3-byte JSR. */ + fprintf(f, "if (g_cpu.S != _cbs) g_cpu.S = _cbs; }\n"); + } else if (absorb_jsr_ret_matches(cfg, abs16)) { + /* Callee consumed the pushed return address as data + * (inline bytecode trampoline): restore S, no bail. */ + fprintf(f, "if (g_cpu.S != _cbs) g_cpu.S = _cbs; }\n"); + /* The interpreter's exit RTS resumes at site+5: it pops + * the pushed return (site+2) and the callee consumed the + * 2 inline data bytes after the operand. Falling through + * to site+3 would execute script DATA as code (verified + * on Tetris: $8217's site+3/4 are $17 $AD — script bytes, + * not instructions). Return 5 so the emitter continues at + * site+5 and emits its label there, making the goto valid. + * Same consumption model as inline_pointer (also returns + * 5). Non-trivial-bank mappers keep the old fall-through: + * window boundaries could leave label_site5 unemitted. */ + if (rom->mapper != 4 && rom->mapper != 40) { + fprintf(f, "nes_cpu_instruction_boundary(0x%04X, 1); goto label_%04X; return;\n", + (uint16_t)(pc + 2), (uint16_t)(pc + 5)); + return 5; + } } else { if (did_return_adjust_jsr) { fprintf(f, "if (g_cpu.S == (uint8_t)(_cbs - 2) && g_rts_target != 0 && g_rts_target != _rp) { call_by_address((uint16_t)(g_rts_target + 1)); }\n"); @@ -3221,7 +3302,8 @@ static int emit_instruction(FILE *f, const NESRom *rom, int bank, rom_read(rom, bank, pc-1) == 0x48 && rom_read(rom, bank, pc-5) == 0x48 && rom_read(rom, bank, pc-9) == 0x48 && - rom_read(rom, bank, pc-12) == 0x48) { + rom_read(rom, bank, pc-12) == 0x48 && + is_insn_boundary_from(rom, bank, func_base, pc, pc)) { fprintf(f, "{ uint8_t _s4=g_cpu.S; g_cpu.S++; uint8_t _lo=g_ram[0x100+g_cpu.S]; g_cpu.S++; uint8_t _hi=g_ram[0x100+g_cpu.S]; call_by_address(((uint16_t)_hi<<8|_lo)+1); g_cpu.S=(uint8_t)(_s4+4); } goto label_%04X;\n ", (uint16_t)(pc+1)); /* Detect 2-PHA RTS-as-JMP: LDA hi/PHA / LDA lo/PHA / RTS. * Also detect "outer continuation" pattern: function starts with @@ -3230,7 +3312,8 @@ static int emit_instruction(FILE *f, const NESRom *rom, int bank, * misses this because the PHAs are not at fixed offsets (computation * breaks the pc-9/pc-12 spacing). After calling the inner handler, * we must also call the static outer continuation. */ - } else if (pc >= 0x8001 && (pc - 1) >= func_base && rom_read(rom, bank, pc - 1) == 0x48 /* PHA */) { + } else if (pc >= 0x8001 && (pc - 1) >= func_base && rom_read(rom, bank, pc - 1) == 0x48 /* PHA */ && + is_insn_boundary_from(rom, bank, func_base, (uint16_t)(pc - 1), pc)) { /* Check for outer continuation: scan backwards for LDA #hi/PHA/LDA #lo/PHA. * Works both when the pattern is at func_base (original case) and when it * starts at a branch-target block entry within the function (e.g., $BAD9 @@ -3642,7 +3725,8 @@ static void emit_function(FILE *f, const NESRom *rom, const FunctionEntry *fe, rom_read(rom, bank, scan-1) == 0x48 && rom_read(rom, bank, scan-5) == 0x48 && rom_read(rom, bank, scan-9) == 0x48 && - rom_read(rom, bank, scan-12) == 0x48) { + rom_read(rom, bank, scan-12) == 0x48 && + is_insn_boundary_from(rom, bank, pc, scan, scan)) { uint16_t cont = scan + 1; bool found = false; for (int p = 0; p < ps_pending_count; p++) @@ -3849,7 +3933,8 @@ static void emit_function(FILE *f, const NESRom *rom, const FunctionEntry *fe, rom_read(rom, bank, cursor-1) == 0x48 && rom_read(rom, bank, cursor-5) == 0x48 && rom_read(rom, bank, cursor-9) == 0x48 && - rom_read(rom, bank, cursor-12) == 0x48) { + rom_read(rom, bank, cursor-12) == 0x48 && + is_insn_boundary_from(rom, bank, pc, cursor, cursor)) { uint16_t cont = cursor + 1; bool found = false; for (int p = 0; p < pending_count; p++) @@ -3860,7 +3945,8 @@ static void emit_function(FILE *f, const NESRom *rom, const FunctionEntry *fe, /* If this RTS is a direct branch target, the PHA setup was bypassed — * emit a regular return instead of a 2-PHA dispatch. */ if (e->mnemonic == MN_RTS && cursor >= 0x8001 && - rom_read(rom, bank, cursor - 1) == 0x48) { + rom_read(rom, bank, cursor - 1) == 0x48 && + is_insn_boundary_from(rom, bank, pc, (uint16_t)(cursor - 1), cursor)) { bool is_branch_tgt = false; for (int b = 0; b < branch_targets_count; b++) if (branch_targets[b] == cursor) { is_branch_tgt = true; break; } diff --git a/recompiler/src/game_config.c b/recompiler/src/game_config.c index 7090fe8..2649a88 100644 --- a/recompiler/src/game_config.c +++ b/recompiler/src/game_config.c @@ -335,6 +335,20 @@ static bool game_config_load_toml(GameConfig *cfg, const char *path) { if (t) cfg->return_adjust_funcs[cfg->return_adjust_func_count++] = toml_hex(t, "addr"); } + /* [[absorb_jsr_ret]] */ + toml_array_t *ajr = toml_array_in(root, "absorb_jsr_ret"); + if (ajr) for (int i = 0; i < toml_array_nelem(ajr) && cfg->absorb_jsr_ret_count < GAME_CFG_MAX_NOP_JSRS; i++) { + toml_table_t *t = toml_table_at(ajr, i); + if (t) cfg->absorb_jsr_rets[cfg->absorb_jsr_ret_count++] = toml_hex(t, "addr"); + } + + /* [[restore_jsr]] */ + toml_array_t *rjs = toml_array_in(root, "restore_jsr"); + if (rjs) for (int i = 0; i < toml_array_nelem(rjs) && cfg->restore_jsr_count < GAME_CFG_MAX_NOP_JSRS; i++) { + toml_table_t *t = toml_table_at(rjs, i); + if (t) cfg->restore_jsrs[cfg->restore_jsr_count++] = toml_hex(t, "addr"); + } + /* [[push_jmp]] — JMP targets that need a dummy push (bail-containing funcs). * Optional `source` field restricts the push to a specific JMP site PC. */ toml_array_t *pjm = toml_array_in(root, "push_jmp"); diff --git a/recompiler/src/game_config.h b/recompiler/src/game_config.h index fa9c882..e9ec257 100644 --- a/recompiler/src/game_config.h +++ b/recompiler/src/game_config.h @@ -283,6 +283,19 @@ typedef struct { uint16_t return_adjust_funcs[GAME_CFG_MAX_NOP_JSRS]; /* JSR targets that rewrite their RTS operand */ int return_adjust_func_count; + /* JSR targets whose callee consumes the pushed return address as data + * (e.g. inline bytecode trampolines). At push_jsr sites for these targets + * the post-call S-mismatch bail is replaced with an S restore. */ + uint16_t absorb_jsr_rets[GAME_CFG_MAX_NOP_JSRS]; + int absorb_jsr_ret_count; + + /* JSR targets whose callee leaves the pushed return address on the 6502 + * stack (its RTS is a plain C return). At push_jsr sites for these + * targets the post-call S-mismatch bail is replaced with an S restore, + * and execution falls through to site+3 (no goto). */ + uint16_t restore_jsrs[GAME_CFG_MAX_NOP_JSRS]; + int restore_jsr_count; + struct { uint16_t target; /* JMP destination that needs the dummy push */ uint16_t source; /* 0 = match any JMP site; nonzero = only this JMP source PC */ diff --git a/runner/include/nes_runtime.h b/runner/include/nes_runtime.h index 0b69122..dd00a12 100644 --- a/runner/include/nes_runtime.h +++ b/runner/include/nes_runtime.h @@ -442,6 +442,12 @@ extern uint64_t g_frame_count; * the fixed-frame-length (29781) error. See DIFFERENTIAL-COSIM-PROPOSAL Rung 1. */ extern uint64_t g_nes_cycles; +/* Guest CPU-cycle stamp of the last VBlank frame boundary (pre-handler). + * Delta between consecutive boundary stamps = the true, essentially-constant + * frame length — the value audio pacing should use to size its per-frame + * sample push. */ +extern uint64_t g_frame_boundary_cyc; + /* Save the current native framebuffer as a PNG */ void runner_screenshot(const char *path); diff --git a/runner/src/launcher.c b/runner/src/launcher.c index c76bac5..1530a85 100644 --- a/runner/src/launcher.c +++ b/runner/src/launcher.c @@ -530,43 +530,61 @@ int main(int argc, char *argv[]) { } } #endif - - if (!gui_resolved) - if (argc >= 2 && argv[1][0] != '-') { - /* Backwards-compatible: ROM path given on command line */ - strncpy(rom_path, argv[1], sizeof(rom_path) - 1); - /* Still verify CRC, but don't re-prompt on mismatch — just warn */ - if (expected_crc != 0 && !verify_rom(rom_path, expected_crc)) { - fprintf(stderr, "[Launcher] Warning: CRC mismatch for '%s' — continuing anyway\n", - rom_path); + if (!gui_resolved) { + /* Check for explicit --rom or --rom= flag first. */ + const char *explicit_rom = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--rom") == 0 && i + 1 < argc) { + explicit_rom = argv[i + 1]; + break; + } + if (strncmp(argv[i], "--rom=", 6) == 0) { + explicit_rom = argv[i] + 6; + break; + } } - } else { - /* Try rom.cfg first */ - rom_cfg_read(rom_path, sizeof(rom_path)); - - int valid = 0; - while (!valid) { - if (rom_path[0] == '\0') { - /* No saved path — open picker */ - if (!pick_rom_file(rom_path, sizeof(rom_path))) { - fprintf(stderr, "[Launcher] No ROM selected — exiting.\n"); - return 1; - } + if (explicit_rom && explicit_rom[0] != '\0') { + strncpy(rom_path, explicit_rom, sizeof(rom_path) - 1); + /* Still verify CRC, but do not re-prompt on mismatch — just warn */ + if (expected_crc != 0 && !verify_rom(rom_path, expected_crc)) { + fprintf(stderr, "[Launcher] Warning: CRC mismatch for '%s' — continuing anyway\n", + rom_path); + } + } else if (argc >= 2 && argv[1][0] != '-') { + /* Backwards-compatible: ROM path given on command line */ + strncpy(rom_path, argv[1], sizeof(rom_path) - 1); + /* Still verify CRC, but don't re-prompt on mismatch — just warn */ + if (expected_crc != 0 && !verify_rom(rom_path, expected_crc)) { + fprintf(stderr, "[Launcher] Warning: CRC mismatch for '%s' — continuing anyway\n", + rom_path); } + } else { + /* Try rom.cfg first */ + rom_cfg_read(rom_path, sizeof(rom_path)); + + int valid = 0; + while (!valid) { + if (rom_path[0] == '\0') { + /* No saved path — open picker */ + if (!pick_rom_file(rom_path, sizeof(rom_path))) { + fprintf(stderr, "[Launcher] No ROM selected — exiting.\n"); + return 1; + } + } - /* Verify the ROM */ - if (verify_rom(rom_path, expected_crc)) { - valid = 1; - } else { - /* Wrong file — clear path and pick again */ - rom_path[0] = '\0'; + /* Verify the ROM */ + if (verify_rom(rom_path, expected_crc)) { + valid = 1; + } else { + /* Wrong file — clear path and pick again */ + rom_path[0] = '\0'; + } } - } - rom_cfg_write(rom_path); - printf("[Launcher] ROM: %s\n", rom_path); + rom_cfg_write(rom_path); + printf("[Launcher] ROM: %s\n", rom_path); + } } - /* Re-build argv so that argv[1] == rom_path for the runner. * If argv[1] was the ROM path, extra args start at index 2. * If argv[1] was a flag (starts with '-'), forward all args from index 1. */ @@ -575,8 +593,11 @@ int main(int argc, char *argv[]) { int extra_start = (argc >= 2 && argv[1][0] != '-') ? 2 : 1; new_argv[new_argc++] = argv[0]; new_argv[new_argc++] = rom_path; - for (int i = extra_start; i < argc && new_argc < 63; i++) + for (int i = extra_start; i < argc && new_argc < 63; i++) { + if (strcmp(argv[i], "--rom") == 0 && i + 1 < argc) { i++; continue; } + if (strncmp(argv[i], "--rom=", 6) == 0) continue; new_argv[new_argc++] = argv[i]; + } new_argv[new_argc] = NULL; #if NESRECOMP_ENABLE_MODS diff --git a/runner/src/main_runner.c b/runner/src/main_runner.c index 47e7a1c..74ba623 100644 --- a/runner/src/main_runner.c +++ b/runner/src/main_runner.c @@ -107,6 +107,12 @@ static void finish_frame_callback(void) { longjmp(s_guest_resume_jmp, 1); } +/* [STATE] trace aux state (NESRECOMP_TRACE_STATE) */ +static uint8_t s_state_pre33 = 0; /* $33 before this frame's NMI */ +static int s_state_nmi_en = 0; /* $2000 bit7 at frame boundary */ +static int s_state_depth = 0; /* vblank depth at frame boundary */ +static int s_state_shim_fired = 0; /* legacy nested-NMI shim ran */ + static void run_guest_execution(void) { (void)setjmp(s_guest_resume_jmp); s_guest_resume_ready = 1; @@ -152,7 +158,7 @@ static Uint64 s_benchmark_post_render_ticks = 0; #endif static int headless_run_active(void) { - return s_smoke_frames > 0 || s_benchmark_frames > 0; + return s_smoke_frames > 0 || s_benchmark_frames > 0 || s_script_path != NULL; } static void benchmark_breakdown_reset(void) { @@ -523,7 +529,15 @@ static void watch_render_frame(void) { /* ---- Audio state ---- */ static SDL_AudioDeviceID s_audio_dev = 0; #define AUDIO_SAMPLES_PER_FRAME 735 +#define AUDIO_SOURCE_RATE 44100.0 static int16_t s_audio_frame[AUDIO_SAMPLES_PER_FRAME]; +/* Carry-accumulator sample push: the guest's true frame length (measured as + * the delta between consecutive VBlank boundary cycle stamps) converts into a + * fractional sample count at the APU source rate. Each frame pushes + * floor(carry + frame_len * rate / cpu_hz) samples and keeps the fraction, so + * the audio clock tracks the video clock with zero long-term drift instead of + * assuming a fixed 29780.5-cycle frame. */ +static double s_audio_sample_carry = 0.0; /* SDL audio callback (round-2): runs on the audio thread at the device's steady * cadence and pulls mono samples from the bridge. This is the consumer that the @@ -898,7 +912,7 @@ void nes_vblank_callback(void) { } /* Update controllers from keyboard state via configurable keybinds */ - { + if (!headless_run_active()) { const uint8_t *keys = SDL_GetKeyboardState(NULL); /* P1 may use keyboard or gamepad. P2 is an explicitly assigned gamepad * (or a netplay peer), never a second hidden keyboard layout. */ @@ -1009,6 +1023,13 @@ void nes_vblank_callback(void) { * wall-clock frame regardless of NMI-enable. game_run_nmi is * responsible for gating the game's actual NMI handler on * (g_ppuctrl & 0x80) and the nested-depth check internally. */ + if (getenv("NESRECOMP_TRACE_STATE")) { + static uint64_t s_state_dbg_frame = 0; + s_state_pre33 = g_ram[0x33]; + s_state_nmi_en = (g_ppuctrl >> 7) & 1; + s_state_depth = runtime_get_vblank_depth(); + s_state_dbg_frame = g_frame_count; + } if ((g_ppuctrl & 0x80) && runtime_get_vblank_depth() > 1) { if (g_nested_nmi_policy == NESTED_NMI_RUN_HANDLER) { /* Re-entrant-NMI game (e.g. SMB3): its handler is nested-safe by @@ -1040,10 +1061,12 @@ void nes_vblank_callback(void) { } else { /* Legacy: skip the handler (would corrupt mid-VRAM transfer * state), but still set $1A/$20 to resolve any spin-wait (SMB). - * Do NOT run game_run_nmi — nested NMIs should not advance - * oracle/frame cadence. */ + * Tetris also waits on $33 set by its NMI handler, so mirror + * the vblank flag there too. */ g_ram[0x1A] = 1; g_ram[0x20] = 1; + g_ram[0x33] = 1; + s_state_shim_fired = 1; } } else { /* Top-level frame boundary (or NMI-disabled frame): delegate to @@ -1113,6 +1136,28 @@ void nes_vblank_callback(void) { s_benchmark_post_nmi_ticks += SDL_GetPerformanceCounter() - benchmark_phase_start; + /* [STATE] per-frame debug tap (env NESRECOMP_TRACE_STATE): post-NMI ZP + * snapshot for NMI-mode/palette-gate debugging ($BD = NMI mode, $BE/$A3 + * gate the palette upload loops in func_94EE). */ + if (getenv("NESRECOMP_TRACE_STATE")) { + fprintf(stderr, + "[STATE] f=%llu pre33=%02X nmiEn=%d depth=%d shim=%d " + "33=%02X 68=%02X 69=%02X BD=%02X BE=%02X " + "A3=%02X B9=%02X B0=%02X C0=%02X B1=%02X mask=%02X\n", + (unsigned long long)g_frame_count, + s_state_pre33, s_state_nmi_en, s_state_depth, + s_state_shim_fired, + g_ram[0x33], g_ram[0x68], g_ram[0x69], g_ram[0xBD], + g_ram[0xBE], g_ram[0xA3], g_ram[0xB9], g_ram[0xB0], + g_ram[0xC0], g_ram[0xB1], g_ppumask); + if (g_frame_count % 60 == 0) { + fprintf(stderr, "[PALDUMP] f=%llu pal:", (unsigned long long)g_frame_count); + for (int i = 0; i < 32; i++) fprintf(stderr, " %02X", g_ppu_pal[i]); + fprintf(stderr, "\n"); + } + s_state_shim_fired = 0; + } + /* Record frame state to ring buffer for TCP timeseries queries */ debug_server_record_frame(); @@ -1142,20 +1187,45 @@ void nes_vblank_callback(void) { * advances for NMI-disabled main-thread code such as the blargg APU tests.) */ /* Generate one frame of audio after NMI (APU registers now up-to-date). - * Skip in turbo mode — queued audio would pile up faster than it drains. */ + * Skip in turbo mode — queued audio would pile up faster than it drains. + * + * Carry-accumulator pacing: the guest's true frame length (delta of + * consecutive VBlank boundary cycle stamps) is converted to a fractional + * sample count at the APU source rate. Each frame pushes + * floor(carry + frame_cycles * rate / cpu_hz) samples and retains the + * fraction, so the pushed audio tracks the video clock exactly (long-term + * drift = 0) instead of a fixed 735 samples per nominal 29781-cycle frame. + * cpu_hz = 21477272.727 (master) / 12 = 1789772.727; NTSC APU rate 44100. */ if (s_audio_dev && !turbo_active() && !headless_run_active()) { + static uint64_t s_last_boundary_cyc = 0; + static int s_have_boundary = 0; + int push_n = AUDIO_SAMPLES_PER_FRAME; + if (s_have_boundary && g_frame_boundary_cyc > s_last_boundary_cyc) { + uint64_t delta = g_frame_boundary_cyc - s_last_boundary_cyc; + /* clamp to one-frame outliers (startup gaps, debugger stalls) */ + if (delta >= 20000 && delta <= 45000) { + double want = s_audio_sample_carry + + (double)delta * (AUDIO_SOURCE_RATE / 1789772.727); + push_n = (int)want; + if (push_n < 600) push_n = 600; + if (push_n > 900) push_n = 900; + s_audio_sample_carry = want - (double)push_n; + } + } + s_last_boundary_cyc = g_frame_boundary_cyc; + s_have_boundary = 1; static int s_synth_mode = -2; /* -2 = not yet queried */ static uint64_t s_synth_pos = 0; if (s_synth_mode == -2) s_synth_mode = recomp_audio_synth_mode(); if (s_synth_mode != RAD_SYNTH_OFF) recomp_audio_synth_fill(s_synth_mode, s_audio_frame, - AUDIO_SAMPLES_PER_FRAME, 1, 44100.0, &s_synth_pos); + push_n, 1, AUDIO_SOURCE_RATE, &s_synth_pos); else - apu_generate(s_audio_frame, AUDIO_SAMPLES_PER_FRAME); + apu_generate(s_audio_frame, push_n); /* T1: raw emulator-rate PCM, before volume. */ recomp_audio_debug_push_i16("t1_apu", s_audio_frame, - AUDIO_SAMPLES_PER_FRAME, 44100.0, 1); + push_n, AUDIO_SOURCE_RATE, 1); /* T0: per-channel raw DAC levels for the same 735-sample window * (pulse1/pulse2/triangle/noise/dmc), staged by apu_generate. NULL @@ -1166,29 +1236,29 @@ void nes_vblank_callback(void) { for (int t0c = 0; t0c < 5; t0c++) { const int16_t *t0buf = apu_debug_t0(t0c); if (t0buf) recomp_audio_debug_push_i16(t0_names[t0c], t0buf, - AUDIO_SAMPLES_PER_FRAME, 44100.0, 1); + push_n, AUDIO_SOURCE_RATE, 1); } } /* Trusted mods share the NES device and clock-domain bridge. Mix on * the producer thread before launcher volume so APU and overlays obey * the same user setting and the callback remains game-agnostic. */ - nes_mod_audio_mix(s_audio_frame, AUDIO_SAMPLES_PER_FRAME); + nes_mod_audio_mix(s_audio_frame, push_n); /* Apply the launcher volume (0..100) as a linear scale. */ int vol = g_nes_config.volume; if (vol < 100) { if (vol < 0) vol = 0; - for (int i = 0; i < AUDIO_SAMPLES_PER_FRAME; i++) + for (int i = 0; i < push_n; i++) s_audio_frame[i] = (int16_t)((int)s_audio_frame[i] * vol / 100); } /* T2: what the bridge receives. */ recomp_audio_debug_push_i16("t2_bridge_in", s_audio_frame, - AUDIO_SAMPLES_PER_FRAME, 44100.0, 1); + push_n, AUDIO_SOURCE_RATE, 1); if (s_bridge_ready) { SDL_LockMutex(s_audio_mtx); - rab_push(&s_bridge, s_audio_frame, AUDIO_SAMPLES_PER_FRAME); + rab_push(&s_bridge, s_audio_frame, push_n); double fill = rab_fill_ms(&s_bridge); rab_stats st; rab_get_stats(&s_bridge, &st); SDL_UnlockMutex(s_audio_mtx); @@ -1199,9 +1269,11 @@ void nes_vblank_callback(void) { (unsigned long long)st.stretch_frames, (unsigned long long)st.stretch_events); } else { - /* legacy fallback (bridge failed to init): old push path */ - SDL_QueueAudio(s_audio_dev, s_audio_frame, - AUDIO_SAMPLES_PER_FRAME * sizeof(int16_t)); + /* legacy fallback (bridge failed to init): raw queue push, capped + * so a stalled consumer cannot accumulate an unbounded backlog. */ + if (SDL_GetQueuedAudioSize(s_audio_dev) < (Uint32)(AUDIO_SOURCE_RATE * 0.15 * sizeof(int16_t))) + SDL_QueueAudio(s_audio_dev, s_audio_frame, + push_n * sizeof(int16_t)); } } @@ -1254,6 +1326,17 @@ void nes_vblank_callback(void) { benchmark_phase_start = benchmark_measure_callback ? SDL_GetPerformanceCounter() : 0; ppu_render_frame(s_framebuf); + { extern uint32_t g_ppu_render_calls, g_ppu_render_skipped; + extern uint8_t g_ppu_render_last_mask, g_ppu_render_last_pal0; + if (getenv("NESRECOMP_TRACE_RENDER")) { + extern uint8_t g_ppu_pal[0x20]; + fprintf(stderr, "[RENDER] f=%llu mask=$%02X pal0=$%02X calls=%u skipped=%u pal:", + (unsigned long long)g_frame_count, + g_ppu_render_last_mask, g_ppu_render_last_pal0, + g_ppu_render_calls, g_ppu_render_skipped); + for (int _i = 0; _i < 8; _i++) fprintf(stderr, " %02X", g_ppu_pal[_i]); + fprintf(stderr, "\n"); + } } if (benchmark_measure_callback) s_benchmark_ppu_ticks += SDL_GetPerformanceCounter() - benchmark_phase_start; @@ -1739,16 +1822,16 @@ int nesrecomp_runner_run(int argc, char *argv[]) { rc.channels = 1; rc.source_rate = 44100.0; rc.host_rate = (double)got.freq; - /* Tuned to the measured video-paced burst swing (~100 ms): a deep ring - * with a high target absorbs producer droughts so the device never - * starves. Latency cost ~90 ms is inaudible for these games. */ - rc.target_ms = 60.0; + /* Video-paced bursts (~100 ms swing) plus the carry-accumulator + * push variance need a moderately deep target; 40 ms holds the + * ring near its steady state without starving the callback. */ + rc.target_ms = 40.0; rc.ring_ms = 250.0; - /* Allow up to +/-1.5% ratio correction so the controller can track a + /* Allow up to +/-3% ratio correction so the controller can track a * real producer/consumer clock mismatch and hold the fill at target * instead of drifting toward underrun/overflow. Only the steady-state * offset is continuous; for matched clocks it sits near 0. */ - rc.max_correction = 0.015; + rc.max_correction = 0.03; /* Phase-1 boot pre-roll: prime the ring to ~200 ms before playback so * the cold-start hitch (JIT warm-up / first audio bursts) is concealed. * The added latency is irrelevant pre-gameplay; the servo drains the diff --git a/runner/src/ppu_renderer.c b/runner/src/ppu_renderer.c index 6546d50..ac4fae6 100644 --- a/runner/src/ppu_renderer.c +++ b/runner/src/ppu_renderer.c @@ -290,7 +290,7 @@ void ppu_render_oam_debug(uint32_t *buf) { /* Diagnostic counters for the title-screen first-divergence investigation. * Disabled by default — re-enable by setting RECOMP_RENDER_DIAG to 1. */ -#define RECOMP_RENDER_DIAG 0 +#define RECOMP_RENDER_DIAG 1 #if RECOMP_RENDER_DIAG uint32_t g_ppu_render_calls = 0; uint32_t g_ppu_render_skipped = 0; diff --git a/runner/src/runtime.c b/runner/src/runtime.c index f43e852..6bcc591 100644 --- a/runner/src/runtime.c +++ b/runner/src/runtime.c @@ -405,6 +405,33 @@ void nes_brk_executed(uint16_t pc) { pc, g_current_bank, (unsigned long long)g_frame_count); fflush(stdout); last = key; + /* [BRKCTX] NESRECOMP_BRK_CTX: full CPU context on the first BRK at a + * site — regs, ZP dispatch/pointer vars, 6502 stack top bytes, and the + * recomp call stack (the guest call chain that led here). */ + if (getenv("NESRECOMP_BRK_CTX")) { + fprintf(stderr, + "[BRKCTX] f=%llu pc=%04X bank=%d A=%02X X=%02X Y=%02X S=%02X P=%c%c1%c%c%c%c " + "ZP: 00=%02X 01=%02X 04=%02X 05=%02X BD=%02X C0=%02X 68=%02X 69=%02X F5=%02X FF=%02X\n", + (unsigned long long)g_frame_count, pc, g_current_bank, + g_cpu.A, g_cpu.X, g_cpu.Y, g_cpu.S, + g_cpu.N ? 'N' : 'n', g_cpu.V ? 'V' : 'v', g_cpu.D ? 'D' : 'd', + g_cpu.I ? 'I' : 'i', g_cpu.Z ? 'Z' : 'z', g_cpu.C ? 'C' : 'c', + g_ram[0x00], g_ram[0x01], g_ram[0x04], g_ram[0x05], + g_ram[0xBD], g_ram[0xC0], g_ram[0x68], g_ram[0x69], + g_ram[0xF5], g_ram[0xFF]); + fprintf(stderr, "[BRKCTX] stack top (S=%02X):", g_cpu.S); + for (int i = 0; i < 16; i++) + fprintf(stderr, " %02X", g_ram[0x100 + ((uint8_t)(g_cpu.S + 1 + i))]); + fprintf(stderr, "\n[BRKCTX] recomp call chain:"); + { + extern const char *g_recomp_stack[]; + extern int g_recomp_stack_top; + for (int i = g_recomp_stack_top - 1; i >= 0; i--) + fprintf(stderr, " %s", g_recomp_stack[i] ? g_recomp_stack[i] : "?"); + } + fprintf(stderr, "\n"); + fflush(stderr); + } } switch (g_brk_policy) { @@ -945,12 +972,41 @@ static void set_guest_execution_point(uint16_t cpu_pc, int tick_charged) { s_guest_tick_charged = tick_charged ? 1 : 0; } +/* [LOOP] trace state (NESRECOMP_TRACE_LOOP) */ +static unsigned s_loop_iters = 0; +static uint16_t s_loop_last_pc = 0; + void nes_cpu_instruction_boundary(uint16_t cpu_pc, int cycles) { set_guest_execution_point(cpu_pc, 1); if (s_skip_next_boundary_tick && cpu_pc == s_guest_resume_pc) { s_skip_next_boundary_tick = 0; return; } + /* [LOOP] per-frame main-loop iteration trace (env NESRECOMP_TRACE_LOOP). + * Key PCs in Tetris (USA): $8138 = inner screen-mode loop head, $8200 = + * title-screen init (mode 0), $8765 = play loop body, $819B = main-mode + * dispatch. Counted once per frame; emitted from the post-NMI hook. */ + { + static int s_loop_state = -1; + if (s_loop_state < 0) s_loop_state = getenv("NESRECOMP_TRACE_LOOP") ? 1 : 0; + if (s_loop_state == 1) { + static uint64_t s_seen_frame = 0; + if (g_frame_count != s_seen_frame) { + if (s_seen_frame != 0) + fprintf(stderr, "[LOOP] f=%llu iters=%u lastpc=%04X\n", + (unsigned long long)s_seen_frame, s_loop_iters, + s_loop_last_pc); + s_seen_frame = g_frame_count; + s_loop_iters = 0; + } + if (cpu_pc == 0x8138 || cpu_pc == 0x8200 || cpu_pc == 0x8765 || + cpu_pc == 0x819B || cpu_pc == 0x823F || cpu_pc == 0x828D || + cpu_pc == 0xA459) { + if (s_loop_iters < 2000000) s_loop_iters++; + s_loop_last_pc = cpu_pc; + } + } + } maybe_trigger_vblank(cycles); } @@ -1306,11 +1362,37 @@ static uint8_t s_ppu_io_latch = 0; static uint8_t nes_read_inner(uint16_t addr) { bus_tick(); - if (addr <= 0x1FFF) return g_ram[addr & 0x07FF]; + if (addr <= 0x1FFF) { + uint8_t v = g_ram[addr & 0x07FF]; + /* [AA98R] NESRECOMP_TRACE_AA98: blob reads via LDA (ZP),Y from the + * interpreter loop PCs ($AAF7 control byte, $AACD data bytes, $AAA3 + * address bytes, $AB15/$AB19 new-pointer fetch). Verifies the pointer + * ZP $00/$01 and the bytes the engine consumes. */ + { + static int s_aa98r = -1; + if (s_aa98r < 0) s_aa98r = getenv("NESRECOMP_TRACE_AA98") ? 1 : 0; + if (s_aa98r == 1 && s_guest_pc_valid) { + uint16_t pc = s_guest_pc; + if (pc == 0xAACD || pc == 0xAAF7 || pc == 0xAAA3 || + pc == 0xAB15 || pc == 0xAB19 || pc == 0xAB2E || pc == 0xAB33) + fprintf(stderr, "[AA98R] f=%llu pc=%04X ptr=%04X y=%02X a=%04X v=%02X\n", + (unsigned long long)g_frame_count, pc, + (uint16_t)(g_ram[0x00] | (g_ram[0x01] << 8)), + g_cpu.Y, addr, v); + } + } + return v; + } if (addr >= 0x2000 && addr <= 0x3FFF) return ppu_read_reg(0x2000 + (addr & 7)); if (addr >= 0x4000 && addr <= 0x401F) { if (addr == 0x4015) return apu_read_status(); if (addr == 0x4016) { + if (getenv("NESRECOMP_TRACE_CTRL4016")) + fprintf(stderr, "[CTRL] f=%llu pc=%04X rd=%02X btn=%02X strobe=%d shift=%02X\n", + (unsigned long long)g_frame_count, s_guest_pc, + 0x40 | (s_ctrl1_strobe ? (g_controller1_buttons >> 7) + : (s_ctrl1_shift >> 7) & 1), + g_controller1_buttons, s_ctrl1_strobe, s_ctrl1_shift); if (s_ctrl1_strobe) return 0x40 | (g_controller1_buttons >> 7); uint8_t bit = (s_ctrl1_shift & 0x80) ? 1 : 0; /* Shift MSB-first; fill with 1s so reads past the 8 button bits @@ -1547,7 +1629,33 @@ void nes_write(uint16_t addr, uint8_t val) { if (s_ww_state != 0) wram_write_watch(a, g_ram[a], val); g_ram[a] = val; return; } - if (addr >= 0x2000 && addr <= 0x3FFF) { ppu_write_reg(0x2000 + (addr & 7), val); return; } + if (addr >= 0x2000 && addr <= 0x3FFF) { + /* [AA98W] NESRECOMP_TRACE_AA98: PPU-register writes issued from the + * script engine's $AA9E-$AAE6 range (blob stream handler). Shows the + * value, the write-latch toggle, t/v address, and the ZP pointers the + * engine reads blobs through ($00/$01 stream, $05/$06 chunk). */ + { + static int s_aa98 = -1; + if (s_aa98 < 0) s_aa98 = getenv("NESRECOMP_TRACE_AA98") ? 1 : 0; + if (s_aa98 == 1 && s_guest_pc_valid) { + uint16_t pc = s_guest_pc; + if (pc >= 0xAA9E && pc <= 0xAAE6) + fprintf(stderr, + "[AA98W] f=%llu pc=%04X reg=%04X val=%02X latch=%d t=%04X v=%04X " + "ZP00=%02X%02X ZP05=%02X%02X A=%02X X=%02X Y=%02X S=%02X\n", + (unsigned long long)g_frame_count, pc, addr, val, + g_ppuaddr_latch, s_ppu_t, g_ppuaddr, + g_ram[0x00], g_ram[0x01], g_ram[0x05], g_ram[0x06], + g_cpu.A, g_cpu.X, g_cpu.Y, g_cpu.S); + } + } + if ((addr & 7) == 0 && getenv("NESRECOMP_TRACE_PPUCTRL")) { + fprintf(stderr, "[CTRL] cyc=%llu frame=%llu val=$%02X\n", + (unsigned long long)g_nes_cycles, + (unsigned long long)g_frame_count, val); + } + ppu_write_reg(0x2000 + (addr & 7), val); return; + } if (addr == 0x4014) { uint16_t src = (uint16_t)val << 8; for (int i = 0; i < 256; i++) g_ppu_oam[i] = nes_read(src + i); @@ -1576,6 +1684,9 @@ void nes_write(uint16_t addr, uint8_t val) { return; } if (addr == 0x4016) { + if (getenv("NESRECOMP_TRACE_CTRL4016")) + fprintf(stderr, "[CTRLW] f=%llu pc=%04X val=%02X\n", + (unsigned long long)g_frame_count, s_guest_pc, val); if (val & 1) { s_ctrl1_strobe = true; } else if (s_ctrl1_strobe) { @@ -1679,7 +1790,14 @@ void ppu_write_reg(uint16_t reg, uint8_t val) { s_visible_frame_frame = g_frame_count; } break; - case 0x2001: g_ppumask = val; break; + case 0x2001: + g_ppumask = val; + if (getenv("NESRECOMP_TRACE_PPUMASK")) + fprintf(stderr, "[MASK] f=%llu cyc=%llu val=$%02X depth=%d\n", + (unsigned long long)g_frame_count, + (unsigned long long)s_dbg_instrs_ticked, val, + s_vblank_depth); + break; case 0x2003: g_oamaddr = val; break; case 0x2004: /* Direct OAM writes carry no draw context: plain X in the sidecar. */ @@ -1745,6 +1863,10 @@ void ppu_write_reg(uint16_t reg, uint8_t val) { case 0x2007: { uint16_t a = g_ppuaddr & 0x3FFF; if (a >= 0x3F00) { + if (getenv("NESRECOMP_TRACE_PAL")) + fprintf(stderr, "[PAL] f=%llu a=$%04X v=$%02X depth=%d\n", + (unsigned long long)g_frame_count, a, val, + s_vblank_depth); /* NES palette mirror: $3F10/$3F14/$3F18/$3F1C share storage * with $3F00/$3F04/$3F08/$3F0C (transparent color slots). */ uint8_t idx = a & 0x1F; @@ -2758,6 +2880,29 @@ int nes_dispatch_call(uint16_t addr, int caller_bank) { uint16_t save_wb = g_code_window_base; if (addr >= 0x8000 || (mapper_get_type() == 40 && addr >= 0x6000)) g_code_window_base = addr & 0xE000; + if (addr == 0) { + uint8_t s_lo = g_ram[0x100 + (uint8_t)(g_cpu.S + 1)]; + uint8_t s_hi = g_ram[0x100 + (uint8_t)(g_cpu.S + 2)]; + uint16_t call_site = (uint16_t)(((uint16_t)s_hi << 8) | s_lo); + void *ret = _ReturnAddress(); + uintptr_t base = (uintptr_t)GetModuleHandleW(NULL); + fprintf(stderr, + "[DISPATCH_ZERO] nes_dispatch_call(addr=0) caller_bank=%d " + "window=$%04X S=$%02X call_site_after_jsr=$%04X A=%02X X=%02X Y=%02X " + "ret=%p base=%p rva=%#llx\n", + caller_bank, g_code_window_base, g_cpu.S, call_site, + g_cpu.A, g_cpu.X, g_cpu.Y, ret, (void *)base, + (unsigned long long)((uintptr_t)ret - base)); + void *frames[8] = {0}; + unsigned short nf = CaptureStackBackTrace(0, 8, frames, NULL); + fprintf(stderr, "[DISPATCH_ZERO] stack (depth=%hu):", nf); + for (unsigned short i = 0; i < nf; i++) { + uintptr_t rva = (uintptr_t)frames[i] - base; + fprintf(stderr, " [%d] %p rva=%#llx", i, frames[i], (unsigned long long)rva); + } + fprintf(stderr, "\n"); + fflush(stderr); + } g_nes_dispatch_depth++; int r = call_by_address_cb(addr, caller_bank); g_nes_dispatch_depth--; @@ -2969,6 +3114,31 @@ void nes_record_dispatch_miss_bank(uint16_t addr, uint16_t cpu_addr, int bank) { "A=%02X X=%02X Y=%02X call_site=$%04X\n", addr, bank, cpu_addr, class_name, g_cpu.A, g_cpu.X, g_cpu.Y, call_site_pc); + /* [MISSCTX] NESRECOMP_BRK_CTX: full context on first dispatch miss — + * same field set as [BRKCTX] so RAM-entry / bad-pointer calls can be + * traced back to their caller chain. */ + if (getenv("NESRECOMP_BRK_CTX")) { + fprintf(stderr, + "[MISSCTX] f=%llu target=%04X call_site=%04X A=%02X X=%02X Y=%02X S=%02X " + "ZP: 00=%02X 01=%02X 04=%02X 05=%02X BD=%02X C0=%02X 68=%02X 69=%02X FF=%02X\n", + (unsigned long long)g_frame_count, addr, call_site_pc, + g_cpu.A, g_cpu.X, g_cpu.Y, g_cpu.S, + g_ram[0x00], g_ram[0x01], g_ram[0x04], g_ram[0x05], + g_ram[0xBD], g_ram[0xC0], g_ram[0x68], g_ram[0x69], + g_ram[0xFF]); + fprintf(stderr, "[MISSCTX] stack top (S=%02X):", g_cpu.S); + for (int i = 0; i < 16; i++) + fprintf(stderr, " %02X", g_ram[0x100 + ((uint8_t)(g_cpu.S + 1 + i))]); + fprintf(stderr, "\n[MISSCTX] recomp call chain:"); + { + extern const char *g_recomp_stack[]; + extern int g_recomp_stack_top; + for (int i = g_recomp_stack_top - 1; i >= 0; i--) + fprintf(stderr, " %s", g_recomp_stack[i] ? g_recomp_stack[i] : "?"); + } + fprintf(stderr, "\n"); + fflush(stderr); + } } g_miss_count_any++; g_miss_last_addr = addr;