diff --git a/runner/src/apu_frame_clock.h b/runner/src/apu_frame_clock.h new file mode 100644 index 00000000..f00c2334 --- /dev/null +++ b/runner/src/apu_frame_clock.h @@ -0,0 +1,36 @@ +#pragma once +#include + +#define RTL_MASTER_CYCLES_PER_FRAME 357368ull +#define RTL_APU_CYCLES_PER_FRAME 17088ull + +/* A host iteration can include an NMI-disabled loader spanning many hardware + * frames. Keep its elapsed time when beginning the following iteration. */ +typedef struct RtlApuFrameClock { + uint64_t start_master, start_guest, next_guest, last_duration; +} RtlApuFrameClock; + +static inline void rtl_apu_clock_begin(RtlApuFrameClock *clock, uint64_t master) { + clock->start_master = master; + clock->start_guest = clock->next_guest; +} + +static inline uint64_t rtl_apu_clock_now(const RtlApuFrameClock *clock, + uint64_t master) { + /* RESET may initialize CPU state inside the first host iteration. */ + uint64_t within = master >= clock->start_master ? master - clock->start_master : 0; + return clock->start_guest + within * RTL_APU_CYCLES_PER_FRAME / + RTL_MASTER_CYCLES_PER_FRAME; +} + +static inline uint64_t rtl_apu_clock_finish(RtlApuFrameClock *clock, + uint64_t master) { + uint64_t end = rtl_apu_clock_now(clock, master); + uint64_t minimum = clock->start_guest + RTL_APU_CYCLES_PER_FRAME; + if (end < minimum) end = minimum; /* WAI still advances a hardware frame. */ + clock->last_duration = end - clock->start_guest; + clock->next_guest = end; + /* Leave the current origin intact: raster IRQs can still touch APU ports + * between the completed game iteration and the next clock_begin. */ + return end; +} diff --git a/runner/src/audio_trace.c b/runner/src/audio_trace.c index ec6c7b86..8a538e3b 100644 --- a/runner/src/audio_trace.c +++ b/runner/src/audio_trace.c @@ -159,11 +159,11 @@ static void stats_line(uint32_t ring_fill) { if (!s_stats_header_written) { fprintf(s_stats_out, "# ms produced consumed dropped dropped_audible drop_runs " - "underflows consume_calls occupancy hiwater prod_cpu prod_audio\n"); + "underflows consume_calls occupancy hiwater prod_cpu prod_audio missing_frames\n"); s_stats_header_written = 1; } fprintf(s_stats_out, - "%llu %llu %llu %llu %llu %llu %llu %llu %u %u %llu %llu\n", + "%llu %llu %llu %llu %llu %llu %llu %llu %u %u %llu %llu %llu\n", (unsigned long long)wall_ms(), (unsigned long long)s_stats.produced, (unsigned long long)s_stats.consumed, @@ -174,7 +174,8 @@ static void stats_line(uint32_t ring_fill) { (unsigned long long)s_stats.consume_calls, ring_fill, s_stats.occupancy_highwater, (unsigned long long)s_stats.produced_cpu, - (unsigned long long)s_stats.produced_audio); + (unsigned long long)s_stats.produced_audio, + (unsigned long long)s_stats.output_missing_frames); fflush(s_stats_out); } @@ -207,8 +208,9 @@ void audio_trace_on_fast_forward_discard(uint32_t samples, s_stats.occupancy_current = occupancy_after; } -void audio_trace_on_output_underflow(uint32_t occupancy) { +void audio_trace_on_output_underflow(uint32_t occupancy, uint32_t missing_frames) { s_stats.output_underflows++; + s_stats.output_missing_frames += missing_frames; s_stats.occupancy_current = occupancy; } diff --git a/runner/src/audio_trace.h b/runner/src/audio_trace.h index 366cb76c..d1e295c9 100644 --- a/runner/src/audio_trace.h +++ b/runner/src/audio_trace.h @@ -161,6 +161,7 @@ typedef struct AudioTraceStats { uint64_t fast_forward_discarded; /* stale queued samples removed only * on the fast-forward -> normal edge */ uint64_t output_underflows; /* callbacks with <1 guest-produced block */ + uint64_t output_missing_frames; /* device-rate stereo frames without PCM */ uint64_t consume_calls; /* dsp_getSamples calls (audio callbacks) */ uint64_t reg_writes; /* DSP register writes */ uint64_t kon_writes; /* writes to $4C (KON) */ @@ -262,7 +263,7 @@ void audio_trace_on_pace(int consumer_active, uint32_t baseline_cycles); void audio_trace_on_guest_sync(int frame_boundary, uint64_t cycles); void audio_trace_on_fast_forward_discard(uint32_t samples, uint32_t occupancy_after); -void audio_trace_on_output_underflow(uint32_t occupancy); +void audio_trace_on_output_underflow(uint32_t occupancy, uint32_t missing_frames); /* CPU<->SPC port traffic. port = 0-3. All call sites hold RtlApuLock. * The SPC-read / CPU-read hooks gate internally (value change or fresh * counterpart write); callers pass every access unconditionally. */ diff --git a/runner/src/common_rtl.c b/runner/src/common_rtl.c index 07246261..f742b6b6 100644 --- a/runner/src/common_rtl.c +++ b/runner/src/common_rtl.c @@ -1,4 +1,5 @@ #include "common_rtl.h" +#include "apu_frame_clock.h" #include "common_cpu_infra.h" #include #include @@ -181,15 +182,28 @@ uint64_t g_apu_last_sync_master = 0; * 357368 SNES master cycles and 17088 SPC cycles in this runtime's 60 Hz * model. g_cpu.master_cycles supplies only the within-frame position because * its total per frame varies with recompilation coverage. */ -#define RTL_MASTER_CYCLES_PER_FRAME 357368ull -#define RTL_APU_CYCLES_PER_FRAME 17088ull static uint64_t g_apu_frame_start_master; +static RtlApuFrameClock g_apu_frame_clock; +static bool g_extended_frame_timing; static bool g_apu_frame_time_valid; bool rtl_apu_frame_timeline_active(void) { return g_apu_frame_time_valid; } +double RtlLastFramePeriods(void) { + return g_extended_frame_timing && g_apu_frame_clock.last_duration + ? (double)g_apu_frame_clock.last_duration / RTL_APU_CYCLES_PER_FRAME : 1.0; +} + +void RtlEnableExtendedFrameTiming(void) { + g_extended_frame_timing = true; +} + +bool rtl_apu_extended_frame_timing(void) { + return g_extended_frame_timing; +} + void rtl_apu_snapshot_pacing(uint64_t *frame_start_master, uint8_t *frame_time_valid) { if (frame_start_master) *frame_start_master = g_apu_frame_start_master; if (frame_time_valid) *frame_time_valid = g_apu_frame_time_valid ? 1 : 0; @@ -215,6 +229,8 @@ static int16 g_audio_last_output_r; static void rtl_sync_apu_frame_boundary(void); static uint64_t rtl_apu_guest_cycle(void) { + if (g_extended_frame_timing) + return rtl_apu_clock_now(&g_apu_frame_clock, g_cpu.master_cycles); uint64_t within = g_cpu.master_cycles - g_apu_frame_start_master; if (within >= RTL_MASTER_CYCLES_PER_FRAME) within = RTL_MASTER_CYCLES_PER_FRAME - 1; @@ -310,6 +326,7 @@ static void memory_sli_func(SaveLoadInfo *sli, void *data, size_t n) { void RtlReset(int mode) { snes_frame_counter = 0; g_apu_frame_time_valid = false; + g_apu_frame_clock = (RtlApuFrameClock){0}; g_apu_frame_start_master = g_cpu.master_cycles; g_main_cpu_cycles_estimate = 0; g_apu_pace_cycles_estimate = 0; @@ -532,6 +549,7 @@ bool RtlRunFrame(uint32 inputs) { * duration. */ g_apu_frame_start_master = g_cpu.master_cycles; g_apu_frame_time_valid = true; + rtl_apu_clock_begin(&g_apu_frame_clock, g_cpu.master_cycles); WatchdogFrameStart(); // Watchdog guard: WatchdogCheck() (called per-block in v2 gen) longjmps // here when a frame exceeds 5s, so an infinite loop in recompiled code @@ -1461,8 +1479,9 @@ static void rtl_sync_apu_frame_boundary(void) { /* RtlRunFrame has already incremented snes_frame_counter. This is the exact * boundary after the completed frame; adding its stale within-frame master * offset here would count the frame body twice. */ - uint64_t boundary = (uint64_t)snes_frame_counter * - RTL_APU_CYCLES_PER_FRAME; + uint64_t boundary = g_extended_frame_timing + ? rtl_apu_clock_finish(&g_apu_frame_clock, g_cpu.master_cycles) + : (uint64_t)snes_frame_counter * RTL_APU_CYCLES_PER_FRAME; bool synced = apu_runToGuestCycle(g_snes->apu, boundary, 1u << 20); audio_trace_on_guest_sync(1, g_snes->apu->portClock - before); @@ -1688,7 +1707,7 @@ static void rtl_render_native(Dsp *dsp, int16 *out, int frames) { * cutting, then hold silence. */ s_render_starved = 1; s_render_fade_pos = 0; - audio_trace_on_output_underflow(available); + audio_trace_on_output_underflow(available, (uint32_t)(frames - usable)); int fade = frames - usable; if (fade > RTL_AUDIO_FADE_FRAMES) fade = RTL_AUDIO_FADE_FRAMES; for (int i = 0; i < fade; i++) { diff --git a/runner/src/common_rtl.h b/runner/src/common_rtl.h index de54e502..5d9d14ae 100644 --- a/runner/src/common_rtl.h +++ b/runner/src/common_rtl.h @@ -53,6 +53,14 @@ extern uint64_t g_apu_last_sync_master; * Interpreter fallback must not also add its legacy relative catch-up for the * same elapsed master cycles. */ bool rtl_apu_frame_timeline_active(void); +/* Opt in before the first RtlRunFrame when the host can pace extended guest + * iterations. It MUST advance its simulation deadline by RtlLastFramePeriods + * nominal frame periods after each call, including non-interactive loading. + * Audio stays on one carried guest clock across multi-frame work. Existing + * hosts retain their timing until they adopt this contract. */ +void RtlEnableExtendedFrameTiming(void); +double RtlLastFramePeriods(void); +bool rtl_apu_extended_frame_timing(void); void rtl_apu_snapshot_pacing(uint64_t *frame_start_master, uint8_t *frame_time_valid); void rtl_apu_restore_pacing(uint64_t frame_start_master, uint8_t frame_time_valid); void rtl_accumulate_apu_catchup(void); diff --git a/runner/src/debug_server.c b/runner/src/debug_server.c index 2389e453..3fd6ae67 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -7175,6 +7175,7 @@ static void cmd_audio_stats(const char *args) { "{\"ok\":true,\"produced\":%llu,\"produced_cpu\":%llu,\"produced_audio\":%llu," "\"dropped\":%llu,\"dropped_audible\":%llu,\"drop_runs\":%llu,\"consumed\":%llu," "\"fast_forward_discarded\":%llu,\"output_underflows\":%llu," + "\"output_missing_frames\":%llu," "\"consume_calls\":%llu," "\"reg_writes\":%llu,\"kon_writes\":%llu,\"occupancy_highwater\":%u," "\"occupancy_current\":%u," @@ -7193,6 +7194,7 @@ static void cmd_audio_stats(const char *args) { (unsigned long long)st.drop_runs, (unsigned long long)st.consumed, (unsigned long long)st.fast_forward_discarded, (unsigned long long)st.output_underflows, + (unsigned long long)st.output_missing_frames, (unsigned long long)st.consume_calls, (unsigned long long)st.reg_writes, (unsigned long long)st.kon_writes, st.occupancy_highwater, st.occupancy_current, diff --git a/runner/src/snes/interp_bridge.c b/runner/src/snes/interp_bridge.c index 3f866904..3ce31e83 100644 --- a/runner/src/snes/interp_bridge.c +++ b/runner/src/snes/interp_bridge.c @@ -9,6 +9,7 @@ #include "interp816.h" #include "tier2_capture.h" #include "snes.h" /* Snes, apuCatchupCycles, snes_catchupApu */ +#include "apu.h" #include "superfx.h" #include "cx4.h" #include "sa1.h" @@ -79,7 +80,9 @@ static void bridge_apu_flush(CpuState *cpu) { #endif if (interp_bridge_use_absolute_apu_timeline( rtl_apu_frame_timeline_active(), - g_snes && cart_has_sa1(g_snes->cart))) { + g_snes && cart_has_sa1(g_snes->cart), + rtl_apu_extended_frame_timing() && + g_snes && g_snes->apu && g_snes->apu->portTimeValid)) { /* Skip snes_catchupApu (legacy relative catch-up) to avoid * double-counting with the absolute guest-clock sync below. * But we MUST still call rtl_sync_apu_to_cpu_locked() to advance @@ -1721,9 +1724,10 @@ static int _interp_run_core(CpuState *cpu, uint32_t entry_pc24, { /* Guest-time APU, batched (see bridge_apu_flush): accumulate; * convert on APU-port access / ~4096 master / exits. */ - if (!interp_bridge_use_absolute_apu_timeline( + if (rtl_apu_extended_frame_timing() || + !interp_bridge_use_absolute_apu_timeline( rtl_apu_frame_timeline_active(), - g_snes && cart_has_sa1(g_snes->cart))) { + g_snes && cart_has_sa1(g_snes->cart), false)) { s_apu_pending_master += _master; if (s_apu_pending_master >= bridge_bounce_flush_thresh()) bridge_apu_flush(cpu); } diff --git a/runner/src/snes/interp_bridge.h b/runner/src/snes/interp_bridge.h index 707925d8..f5acf591 100644 --- a/runner/src/snes/interp_bridge.h +++ b/runner/src/snes/interp_bridge.h @@ -36,13 +36,13 @@ * 1 accelerated. Native interrupt helpers retain their ordinary policy. */ void interp_bridge_set_scheduler_aot_policy(int enabled); -/* SA-1's frame timeline already advances the SPC to absolute guest time, so - * its interpreter must not also apply the legacy relative catch-up. Ordinary - * SNES cartridges still require that catch-up during interpreter-heavy boot - * code (Mega Man X otherwise stalls in task 0 at the copyright screen). */ +/* Once port time is mapped, the frame timeline and interpreter catch-up + * describe the same elapsed time. Extended-frame hosts use absolute sync; + * legacy hosts and unmapped boot (e.g. Mega Man X's IPL polling) keep relative + * catch-up. SA-1 already uses absolute frame time. */ static inline bool interp_bridge_use_absolute_apu_timeline( - bool frame_timeline_active, bool is_sa1) { - return frame_timeline_active && is_sa1; + bool frame_timeline_active, bool is_sa1, bool mapped_extended_frame) { + return frame_timeline_active && (is_sa1 || mapped_extended_frame); } /* Optional game policy invoked immediately before one interpreted opcode. diff --git a/tests/audio/audio_trace_history_test.c b/tests/audio/audio_trace_history_test.c index badc7fc8..11729072 100644 --- a/tests/audio/audio_trace_history_test.c +++ b/tests/audio/audio_trace_history_test.c @@ -141,7 +141,7 @@ int main(void) { audio_trace_on_consume(0, 640, 12); audio_trace_on_fast_forward_discard(3, 9); - audio_trace_on_output_underflow(1); + audio_trace_on_output_underflow(1, 17); audio_trace_on_pace(1, 77); audio_trace_on_guest_sync(1, 1000); audio_trace_on_guest_sync(0, 2000); @@ -166,6 +166,7 @@ int main(void) { expect_u32("occupancy_current", st.occupancy_current, 1); expect_u64("fast_forward_discarded", st.fast_forward_discarded, 3); expect_u64("output_underflows", st.output_underflows, 1); + expect_u64("output_missing_frames", st.output_missing_frames, 17); expect_u64("pace_baseline_cycles", st.pace_baseline_cycles, 77); expect_u64("pace_accumulate_calls", st.pace_accumulate_calls, 1); expect_u32("pace_consumer_active", st.pace_consumer_active, 1); diff --git a/tests/interp816/bridge_test.c b/tests/interp816/bridge_test.c index a1c6019a..0fb5ff7f 100644 --- a/tests/interp816/bridge_test.c +++ b/tests/interp816/bridge_test.c @@ -24,6 +24,7 @@ #include "interp_bridge.h" /* -> cpu_state.h (types, inline frame helpers) */ #include "tier2_capture.h" #include "snes.h" /* Snes storage for the bridge's APU clock hook */ +#include "apu.h" #include "sa1.h" #include "snes_cycles.h" @@ -56,7 +57,11 @@ static Snes g_test_snes; Snes *g_snes = &g_test_snes; uint64_t g_apu_last_sync_master; int g_interp_apu_driving; -bool rtl_apu_frame_timeline_active(void) { return false; } +static bool g_frame_timeline, g_extended_frames; +static unsigned g_absolute_syncs, g_relative_syncs; +bool rtl_apu_frame_timeline_active(void) { return g_frame_timeline; } +bool rtl_apu_extended_frame_timing(void) { return g_extended_frames; } +void rtl_sync_apu_to_cpu_locked(void) { ++g_absolute_syncs; } bool sa1_cpu_irq_pending(const Sa1 *sa1) { (void)sa1; return false; } int g_recomp_stack_top; uint16_t g_cpu_entry_s[64]; @@ -67,7 +72,6 @@ void debug_on_block_enter(uint32_t pc, uint32_t a, uint32_t x, uint32_t y) { void RtlApuLock(void) {} void RtlApuUnlock(void) {} void snes_refresh_charge(void) {} -void rtl_sync_apu_to_cpu_locked(void) {} uint32_t cpu_region_speed(uint32_t addr24) { return (uint32_t)snes_region_speed(addr24, g_memsel); } @@ -96,7 +100,7 @@ void RecompStackPop(void) { if (g_push_depth <= 0) g_pop_underflow = 1; g_push_depth--; } -void snes_catchupApu(Snes *snes) { (void)snes; } +void snes_catchupApu(Snes *snes) { (void)snes; ++g_relative_syncs; } void snes_sync_master_clock(Snes *snes, uint64_t master_clock) { (void)snes; (void)master_clock; } @@ -336,14 +340,43 @@ int main(void) { RAM = malloc(MEMSZ); printf("S0 APU timeline policy remains cartridge-scoped\n"); - CHECK(!interp_bridge_use_absolute_apu_timeline(false, false), + CHECK(!interp_bridge_use_absolute_apu_timeline(false, false, false), "inactive non-SA1 timeline must use legacy catch-up"); - CHECK(!interp_bridge_use_absolute_apu_timeline(true, false), + CHECK(!interp_bridge_use_absolute_apu_timeline(true, false, false), "active non-SA1 timeline must use legacy catch-up"); - CHECK(!interp_bridge_use_absolute_apu_timeline(false, true), + CHECK(!interp_bridge_use_absolute_apu_timeline(false, true, false), "inactive SA1 timeline must use legacy catch-up"); - CHECK(interp_bridge_use_absolute_apu_timeline(true, true), + CHECK(interp_bridge_use_absolute_apu_timeline(true, true, false), "active SA1 timeline must suppress duplicate catch-up"); + CHECK(interp_bridge_use_absolute_apu_timeline(true, false, true), + "mapped extended frames must suppress duplicate catch-up"); + CHECK(!interp_bridge_use_absolute_apu_timeline(false, false, true), + "mapped time before the frame loop must retain bootstrap catch-up"); + + /* No APU port touches: long interpreted work must periodically sync the + * absolute clock, while unmapped boot still uses relative catch-up. */ + { Apu apu = {0}; + g_test_snes.apu = &apu; + g_frame_timeline = true; + for (unsigned mode = 0; mode < 3; ++mode) { + memset(RAM, 0, MEMSZ); init_cpu(); + uint8_t c[] = {0xA2,0xFF,0xCA,0xD0,0xFD,0x60}; + load(0x8000, c, sizeof c); + cpu_push_jsr_return_frame(&g_c); + g_extended_frames = mode != 0; + apu.portTimeValid = mode == 2; + g_absolute_syncs = g_relative_syncs = 0; + CHECK(interp_bridge_run(&g_c, 0x008000) == 1, "timed loop returns"); + if (mode == 2) + CHECK(g_absolute_syncs > 1 && g_relative_syncs == 0, + "mapped work syncs repeatedly without double-driving SPC"); + else + CHECK(g_relative_syncs > 1 && g_absolute_syncs >= g_relative_syncs, + "legacy and unmapped boot retain relative progress"); + } + g_test_snes.apu = NULL; + g_frame_timeline = g_extended_frames = false; + } /* S1: LDA #$01 ; JSR $8100 (compiled) ; RTS */ { memset(RAM, 0, MEMSZ); init_cpu(); g_aot_called = 0; diff --git a/tests/runtime_dispatch/apu_port_diag_getenv_test.c b/tests/runtime_dispatch/apu_port_diag_getenv_test.c index d4f6e97b..7c967c81 100644 --- a/tests/runtime_dispatch/apu_port_diag_getenv_test.c +++ b/tests/runtime_dispatch/apu_port_diag_getenv_test.c @@ -42,6 +42,7 @@ char *__wrap_getenv(const char *name) { const char *rtl_game_title(void) { return "apu_port_diag_getenv_test"; } bool rtl_apu_frame_timeline_active(void) { return false; } +bool rtl_apu_extended_frame_timing(void) { return false; } bool sa1_cpu_irq_pending(const Sa1 *sa1) { (void)sa1; return false; } void RtlApuLock(void) {} void RtlApuUnlock(void) {} diff --git a/tests/runtime_dispatch/apu_port_guest_time_test.c b/tests/runtime_dispatch/apu_port_guest_time_test.c index fa38dd53..ee8e7095 100644 --- a/tests/runtime_dispatch/apu_port_guest_time_test.c +++ b/tests/runtime_dispatch/apu_port_guest_time_test.c @@ -3,6 +3,7 @@ #include #include "apu.h" +#include "apu_frame_clock.h" #include "dsp_shadow.h" uint64_t g_apu_timer0_total_ticks; @@ -198,6 +199,43 @@ int main(void) { apu->dsp->sampleWrite == 419, "fast-forward recovery retains only the requested newest PCM"); + /* Long interpreted work advances the real SPC along one absolute clock; + * short frames afterward must produce PCM immediately, not wait 40 frames + * for a host frame counter to catch up. No ROM or wall-clock timing needed. */ + apu_clearPortQueue(apu); + RtlApuFrameClock clock = {0}; + rtl_apu_clock_begin(&clock, 0); + failures += check(apu_schedulePortWrite(apu, 0, 0, 0), "map extended clock"); + uint64_t before_long = apu->portClock; + for (unsigned quarter = 1; quarter <= 160; ++quarter) { + uint64_t master = quarter * RTL_MASTER_CYCLES_PER_FRAME / 4; + failures += check(apu_runToGuestCycle(apu, rtl_apu_clock_now(&clock, master), + 1u << 20), "progress inside long loader"); + apu->dsp->sampleRead = apu->dsp->sampleWrite; + } + uint64_t master_end = 40 * RTL_MASTER_CYCLES_PER_FRAME; + uint64_t end = rtl_apu_clock_finish(&clock, master_end); + failures += check(clock.last_duration == 40 * RTL_APU_CYCLES_PER_FRAME && + apu->portClock - before_long == clock.last_duration, + "long loader clocks the SPC once for all elapsed time"); + failures += check(rtl_apu_clock_now(&clock, master_end) == end, + "finishing must not add the stale within-frame offset again"); + for (unsigned frame = 0; frame < 45; ++frame) { + rtl_apu_clock_begin(&clock, master_end); + failures += check(rtl_apu_clock_now(&clock, master_end) == end, + "next iteration starts at the completed guest timestamp"); + uint32_t samples_before = apu->dsp->sampleWrite; + master_end += 100; /* Mostly WAI. */ + end = rtl_apu_clock_finish(&clock, master_end); + failures += check(apu_runToGuestCycle(apu, end, 1u << 20) && + apu->dsp->sampleWrite - samples_before == 534, + "every following short frame produces its normal PCM"); + apu->dsp->sampleRead = apu->dsp->sampleWrite; + } + rtl_apu_clock_begin(&clock, master_end); + failures += check(rtl_apu_clock_now(&clock, 0) == clock.start_guest, + "CPU reset cannot underflow the within-frame timestamp"); + #if !SNESRECOMP_SPC_DIAGNOSTICS failures += check(g_spc_pc_histogram[0] == 0 && g_spc_pc_max_seen == 0,