From 629a147ea9b0b8278125ca8d8c5480213347a7df Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 10 Sep 2026 20:26:11 -0700 Subject: [PATCH 1/3] debug: count output frames missing guest audio --- runner/src/audio_trace.c | 10 ++++++---- runner/src/audio_trace.h | 3 ++- runner/src/common_rtl.c | 2 +- runner/src/debug_server.c | 2 ++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/runner/src/audio_trace.c b/runner/src/audio_trace.c index 4dc6faf6..87af7864 100644 --- a/runner/src/audio_trace.c +++ b/runner/src/audio_trace.c @@ -111,11 +111,11 @@ static void stats_line(uint32_t ring_fill) { if (s_stats_mode == 1) 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"); } if (s_stats_mode == 0) return; 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, @@ -126,7 +126,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); } @@ -154,8 +155,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 1e52bf28..72fadcd0 100644 --- a/runner/src/audio_trace.h +++ b/runner/src/audio_trace.h @@ -101,6 +101,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) */ @@ -202,7 +203,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 06ca8c17..e72de36b 100644 --- a/runner/src/common_rtl.c +++ b/runner/src/common_rtl.c @@ -1479,7 +1479,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/debug_server.c b/runner/src/debug_server.c index 6ad466c1..2b2d5b63 100644 --- a/runner/src/debug_server.c +++ b/runner/src/debug_server.c @@ -7025,6 +7025,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," @@ -7043,6 +7044,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, From e2c75fd5876893d5450a35dba4b52ffbd114f905 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 10 Sep 2026 20:48:35 -0700 Subject: [PATCH 2/3] fix: carry APU time across extended frame iterations --- runner/src/apu_frame_clock.h | 36 +++++++++++ runner/src/common_rtl.c | 63 ++++++++++++------- runner/src/common_rtl.h | 8 +++ runner/src/snes/interp_bridge.c | 15 ++++- runner/src/snes/interp_bridge.h | 12 ++-- tests/interp816/bridge_test.c | 46 ++++++++++++-- tests/interp816/run.ps1 | 2 + tests/interp816/run.sh | 3 +- .../apu_port_guest_time_test.c | 38 +++++++++++ 9 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 runner/src/apu_frame_clock.h 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/common_rtl.c b/runner/src/common_rtl.c index e72de36b..864e347c 100644 --- a/runner/src/common_rtl.c +++ b/runner/src/common_rtl.c @@ -1,4 +1,5 @@ -#include "common_rtl.h" +#include "common_rtl.h" +#include "apu_frame_clock.h" #include "common_cpu_infra.h" #include #include @@ -112,14 +113,27 @@ 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 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) { +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; +} /* Fast-forward advances the real SPC/DSP state faster than the host device can * play it. On the transition back to realtime, buffered PCM represents stale @@ -135,7 +149,9 @@ static int16 g_audio_last_output_l; static int16 g_audio_last_output_r; static void rtl_sync_apu_frame_boundary(void); -static uint64_t rtl_apu_guest_cycle(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; /* `within` is deliberately NOT clamped to one frame. * @@ -163,13 +179,13 @@ static uint64_t rtl_apu_guest_cycle(void) { * parked in WAI (which executes almost no master cycles) still gets a full * frame of APU time per frame. * - * Monotonicity is safe without a clamp here: after a long section this can + * Legacy hosts still use frame-count time: after a long section this can * fall behind the next frame boundary, and apu_runToGuestCycle (apu.c:135) * absorbs that -- a target below portGuestAnchor is a no-op and it ratchets * `target` up to portLastTarget. The SPC idles until the frame counter - * catches up, mirroring the wall time the transfer would have taken on - * hardware. */ - return (uint64_t)snes_frame_counter * RTL_APU_CYCLES_PER_FRAME + + * catches up. Hosts supporting extended iterations instead use the carried + * clock above and pace by RtlLastFramePeriods, avoiding this audio stall. */ + return (uint64_t)snes_frame_counter * RTL_APU_CYCLES_PER_FRAME + within * RTL_APU_CYCLES_PER_FRAME / RTL_MASTER_CYCLES_PER_FRAME; } @@ -260,7 +276,8 @@ 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_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; @@ -472,8 +489,9 @@ bool RtlRunFrame(uint32 inputs) { /* Establish the guest timestamp origin before any frame code can touch an * APU port. Host turbo changes how quickly frames arrive, not their guest * duration. */ - g_apu_frame_start_master = g_cpu.master_cycles; - g_apu_frame_time_valid = true; + 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 @@ -1250,27 +1268,26 @@ bool RtlHandleSpcUpload(CpuState *cpu) { return RtlUploadSpcImageFromDpInternal(cpu, true, false); } -static void rtl_sync_apu_frame_boundary(void) { - /* The game frame is the authoritative guest-time clock. The audio callback - * may fill a host scheduling shortfall, but CPU->APU events must never wait - * behind it: advance the real SPC through every event due by this completed - * frame at normal speed and turbo alike. */ +static void rtl_sync_apu_frame_boundary(void) { + /* Advance the real SPC through all events due in this iteration. Audio + * callbacks only consume PCM; they never advance the guest clock. */ RtlApuLock(); audio_trace_set_producer(AUDIO_TRACE_PRODUCER_CPU); uint64_t before = g_snes->apu->portClock; /* 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); audio_trace_set_producer(AUDIO_TRACE_PRODUCER_UNKNOWN); if (!synced) fprintf(stderr, "[apu] frame-boundary guest-clock sync timed out\n"); - RtlApuUnlock(); -} + RtlApuUnlock(); +} void RtlAudioSetFastForward(bool active) { if (!active && !g_audio_fast_forward && g_audio_recovery_frames == 0) diff --git a/runner/src/common_rtl.h b/runner/src/common_rtl.h index 6f08e430..2ed34167 100644 --- a/runner/src/common_rtl.h +++ b/runner/src/common_rtl.h @@ -47,6 +47,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_accumulate_apu_catchup(void); /* Caller holds RtlApuLock. Before the first frame, retain bootstrap synthetic * pacing; afterward synchronize reads to the authoritative guest timestamp. */ diff --git a/runner/src/snes/interp_bridge.c b/runner/src/snes/interp_bridge.c index c6119732..30a2c157 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" @@ -86,9 +87,16 @@ static void bridge_apu_flush(CpuState *cpu) { * sync already advance the SPC to the exact absolute timestamp. */ 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)) { s_apu_pending_master = 0; g_apu_last_sync_master = cpu->master_cycles; + if (rtl_apu_extended_frame_timing()) { + RtlApuLock(); + rtl_sync_apu_to_cpu_locked(); + RtlApuUnlock(); + } return; } RtlApuLock(); @@ -1200,9 +1208,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 >= 4096) bridge_apu_flush(cpu); } diff --git a/runner/src/snes/interp_bridge.h b/runner/src/snes/interp_bridge.h index 7d17e2d8..77c392ca 100644 --- a/runner/src/snes/interp_bridge.h +++ b/runner/src/snes/interp_bridge.h @@ -32,13 +32,13 @@ #include #include "cpu_state.h" -/* 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/interp816/bridge_test.c b/tests/interp816/bridge_test.c index eb5f2ed1..7bf6b0c5 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" CpuState g_cpu; @@ -55,7 +56,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]; @@ -65,7 +70,7 @@ 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_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; } @@ -306,14 +311,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 == 0, + "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/interp816/run.ps1 b/tests/interp816/run.ps1 index 4bc5acac..2e2ee34c 100644 --- a/tests/interp816/run.ps1 +++ b/tests/interp816/run.ps1 @@ -27,11 +27,13 @@ if ($LASTEXITCODE -ne 0) { throw "interp816 core test failed" } $bridgeOut = Join-Path $outDir "bridge_test.exe" Build-BelowNormal @( + "-DSNESRECOMP_TIER2_TEST", "-std=c11", "-Wall", "-Wextra", "-Wno-unused-parameter", "-O1", "-I$root\runner\src", "-I$root\runner\src\snes", "$root\tests\interp816\bridge_test.c", "$root\runner\src\snes\interp816.c", "$root\runner\src\snes\interp_bridge.c", + "$root\runner\src\snes\tier2_capture.c", "$root\runner\src\snes\cx4.c", "-lm", "-o", $bridgeOut ) $bridgeOut diff --git a/tests/interp816/run.sh b/tests/interp816/run.sh index 97abc765..bec51795 100644 --- a/tests/interp816/run.sh +++ b/tests/interp816/run.sh @@ -15,8 +15,9 @@ gcc $CFLAGS -I runner/src/snes \ echo "" echo "=== Phase 1: interp_bridge contract ===" -gcc $CFLAGS -I runner/src -I runner/src/snes \ +gcc $CFLAGS -DSNESRECOMP_TIER2_TEST -I runner/src -I runner/src/snes \ tests/interp816/bridge_test.c \ runner/src/snes/interp816.c runner/src/snes/interp_bridge.c \ + runner/src/snes/tier2_capture.c \ runner/src/snes/cx4.c -lm -o build/bridge_test exec ./build/bridge_test diff --git a/tests/runtime_dispatch/apu_port_guest_time_test.c b/tests/runtime_dispatch/apu_port_guest_time_test.c index 8cda815e..b7447d81 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; @@ -160,6 +161,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"); + apu_free(apu); if (failures) return 1; From a4cfac1066618ad3a5e4a3478c91ecbf0c941214 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 10 Sep 2026 21:37:17 -0700 Subject: [PATCH 3/3] test: update audio harnesses for missing-output telemetry --- tests/audio/audio_trace_history_test.c | 3 ++- tests/runtime_dispatch/apu_port_diag_getenv_test.c | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/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) {}