diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index b53c82e5..0eaae45e 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,18 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- 2026-10-21 BRIEF-4 Stage 3 WIP: restore asynchronous temporal eligibility, + queue presentation deadlines on the presenter, release rejected plans, and + credit frontend deadline sleep without adding overlapping renderer idle. + A bounded size-aware image budget admits two images per generation at 8K + (1 GiB ceiling). Title tests pass Windows/Mac 25/25; schedule/guard checks + pass. R4 guest off/on identity matches 4,501 frames with 735 temporal passes. + Acceptance still FAILS: full-8K transactional redraw quiet 58.528 Hz, + 311 >25 ms completion gaps, p99 busy 65.0%; 33.8 s context-acquire waits. + GPU recorded redraw experiment also failed timing/distinct-frame coverage + and was removed. Queued GPU transactions remain necessary; no Stage 3 push. + R4 `analysis/STATUS.md` links local receipts. Stage 2 remains pushed/accepted. + - 2026-10-21 BRIEF-4 Stage 2: match Windows emulation priority to render/present workers and reuse packed vertex uploads across queued wide/window draws (from `910eef11`, retaining current depth/coverage behavior). R4 at the diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index 4e80efc5..57b92049 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -17,6 +17,9 @@ typedef struct FrameInterpolationSchedule { * plans the whole 0..1 window, which is the historical behaviour. */ double phase_lo; double phase_hi; + /* Bounded asynchronous producer: preparing several frames ahead is normal, + * so an early arrival must not reset the presentation clock. */ + int queued; } FrameInterpolationSchedule; /* Plan one stock guest-frame interval. Presentation deadlines remain anchored diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 4b930a23..6ac0f527 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -95,7 +95,11 @@ void gl_renderer_pass_set_flip_shown(int shown); void gl_renderer_pass_end(uint32_t alpha_q16, int keep); uint32_t gl_renderer_pass_leaks(void); int gl_renderer_pass_verify_vram(void); +/* Relinquish the synchronous context after refusing a temporal plan. */ +void gl_renderer_pass_cancel_plan(void); void gl_renderer_pass_note_cost(uint64_t ticks); +/* Actual frontend deadline sleep; admission uses max with renderer idle. */ +void gl_renderer_pass_note_frontend_idle(uint64_t ticks); /* Present output deadlines that fell due while guest code ran. */ void gl_renderer_pass_service_presents(void); /* promotions, presents, blended presents, expired, unmatched flips, presents diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index 0b4576ed..de760e07 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -45,7 +45,7 @@ int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedul * Normal guest work lands after the prior deadline but before the next one; * retaining the old anchor makes that work consume the pacing budget. */ if (schedule->source_deadline <= 0.0 || - now_d + source_period < schedule->source_deadline || + (!schedule->queued && now_d + source_period < schedule->source_deadline) || now_d > schedule->source_deadline + source_period * 4.0) { schedule->frame_start = now_d; schedule->source_deadline = now_d + source_period; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d0fc0839..27d5c248 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -510,6 +510,7 @@ static int ctx_bg_full(void) { * sync point (the emulation thread draws those frames itself). */ static int ctx_depth24(void) { return rth_replaying() ? 0 : gpu_display_is_depth24(); } static void gl_rth_acquire(const char *reason); +static void gl_rth_release(void); #define GL_RT_SYNC(reason) \ do { if (s_rth_on && !rt_on_render_thread()) gl_rth_acquire(reason); } while (0) /* Recording (defined with GL_RT_BACKEND at the end of the file). */ @@ -522,7 +523,7 @@ enum { RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE, RTH_DYN_STEP, - RTH_FRAME, RTH_FG_SRC, RTH_DEPTH, RTH_HD_NOTE, RTH_DITHER, RTH_WIDE_FEEDBACK + RTH_FRAME, RTH_FG_SRC, RTH_DEPTH, RTH_HD_NOTE, RTH_DITHER, RTH_WIDE_FEEDBACK, RTH_PASS_SERVICE }; /* GP0(A0) whose payload gpu.c is still streaming into its array (the * facade's vram_upload_open, emulation thread), tracked whatever the HD @@ -714,6 +715,8 @@ static double s_interp_host_hz = 0.0; static double s_interp_target_hz = 0.0; static double s_interp_source_hz = 0.0; static FrameInterpolationSchedule s_interp_schedule; +static uint64_t s_interp_pending_deadline; +static _Atomic uint64_t s_pass_frontend_idle; /* Blend source (psx_mod_set_frame_interpolation_source). VBLANK (0, the * default) treats every guest VBlank present as a new frame, exactly as * before. FLIP (1) rotates history only on a real flip -- the display origin @@ -8069,7 +8072,7 @@ static int interp_present(float alpha) { s_interp_blend_mode); } -static void interp_wait_until(uint64_t deadline, uint64_t frequency) { +static void interp_wait_until_ex(uint64_t deadline, uint64_t frequency, int render_idle) { uint64_t now, start; if (!deadline || !frequency) return; start = SDL_GetPerformanceCounter(); @@ -8077,8 +8080,10 @@ static void interp_wait_until(uint64_t deadline, uint64_t frequency) { now = SDL_GetPerformanceCounter(); if (now >= deadline) { /* Idle host time feeds the render-pass budget. */ - (void)s_idle_ticks_accum_fwd(now - start); - if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; + if (render_idle) { + (void)s_idle_ticks_accum_fwd(now - start); + if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; + } return; } uint64_t remain = deadline - now; @@ -8091,8 +8096,17 @@ static void interp_wait_until(uint64_t deadline, uint64_t frequency) { } } +static void interp_wait_until(uint64_t deadline, uint64_t frequency) { + interp_wait_until_ex(deadline, frequency, 1); +} +void gl_renderer_pass_note_frontend_idle(uint64_t ticks) { + atomic_fetch_add_explicit(&s_pass_frontend_idle, ticks, memory_order_relaxed); +} + static void interp_present_source_interval(void) { static uint64_t diag_start, diag_swaps, diag_captures; + const int asynchronous = s_interp_hold && s_pt_on && s_rth_on && rt_on_render_thread(); + s_interp_schedule.queued = asynchronous; uint64_t frequency = SDL_GetPerformanceFrequency(); uint64_t now = SDL_GetPerformanceCounter(); uint64_t deadline; @@ -8108,14 +8122,16 @@ static void interp_present_source_interval(void) { while (frame_interpolation_schedule_next( &s_interp_schedule, SDL_GetPerformanceCounter(), &deadline, &alpha)) { - interp_wait_until(deadline, frequency); + if (!asynchronous) interp_wait_until(deadline, frequency); + s_interp_pending_deadline = asynchronous ? deadline : 0; latency_ring_mark(LAT_SWAP_BEGIN); if (!pass_gen_present(deadline)) (void)interp_present(alpha); latency_ring_mark(LAT_SWAP_END); + s_interp_pending_deadline = 0; } - interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), - frequency); + if (!asynchronous) + interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), frequency); now = SDL_GetPerformanceCounter(); if (!diag_start) { @@ -8343,15 +8359,25 @@ int gl_renderer_pass_ready(void) { return gl_renderer_pass_unavailable() == PSX_MOD_RENDER_PASS_READY; } -/* Slots per generation that fit a 256 MiB budget for the whole queue. */ +/* Keep the historical 256 MiB queue budget, but permit two images per + * generation at the requested capture size, up to a 1 GiB hard ceiling. + * Eight-kilopixel output needs about 760 MiB for those six images; the old + * cap silently disabled temporal rendering above roughly 1440 lines. */ static uint32_t pass_slot_cap(int tex_w, int tex_h) { double bytes = (double)tex_w * (double)tex_h * 4.0 * PASS_GENERATIONS; - uint32_t cap = bytes > 0.0 ? (uint32_t)((256.0 * 1024.0 * 1024.0) / bytes) + double budget = 256.0 * 1024.0 * 1024.0; + if (2.0 * bytes > budget) budget = 2.0 * bytes; + if (budget > 1024.0 * 1024.0 * 1024.0) budget = 1024.0 * 1024.0 * 1024.0; + uint32_t cap = bytes > 0.0 ? (uint32_t)(budget / bytes) : PASS_SLOTS; if (cap > PASS_SLOTS) cap = PASS_SLOTS; return cap; } +void gl_renderer_pass_cancel_plan(void) { + if (s_rth_on && !rt_on_render_thread()) gl_rth_release(); +} + uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t *alpha_q16, uint32_t max, @@ -8365,15 +8391,17 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, if (wanted) *wanted = 0; /* One plan per game frame: close the previous frame's host-time books. */ s_idle_ticks_last = s_idle_ticks_accum; + uint64_t frontend_idle = atomic_exchange_explicit(&s_pass_frontend_idle, 0, memory_order_relaxed); + if (frontend_idle > s_idle_ticks_last) s_idle_ticks_last = frontend_idle; s_pass_ticks_last = s_pass_ticks_accum; s_present_ticks_last = s_present_ticks_accum; s_idle_ticks_accum = s_pass_ticks_accum = s_present_ticks_accum = 0; live = s_intervals_since_plan > 0; /* turbo/headless present nothing */ s_intervals_since_plan = 0; - if (!gl_renderer_pass_ready() || !live || !alpha_q16 || max == 0) return 0; + if (!gl_renderer_pass_ready() || !live || !alpha_q16 || max == 0) { gl_renderer_pass_cancel_plan(); return 0; } if (s_interp_schedule.target_period <= 0.0 || s_interp_schedule.source_deadline <= 0.0) - return 0; + { gl_renderer_pass_cancel_plan(); return 0; } if (s_pass_budget_pct < 0) { const char *e = getenv("PSX_RENDER_PASS_BUDGET"); int v = e ? atoi(e) : 0; @@ -8382,7 +8410,7 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, freq = (double)SDL_GetPerformanceFrequency(); sp = freq / s_interp_source_hz; cap = pass_slot_cap(s_interp_w, s_interp_h); - if (cap < 2) return 0; + if (cap < 2) { gl_renderer_pass_cancel_plan(); return 0; } memset(&in, 0, sizeof in); in.next_deadline = s_interp_schedule.next_present_deadline; in.target_period = s_interp_schedule.target_period; @@ -8432,6 +8460,7 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, if (want && !n && !(in.budget > 0.0)) s_pass_plan_diag.zero_credit_refusals++; } + if (!n) gl_renderer_pass_cancel_plan(); return n; } } @@ -9483,6 +9512,13 @@ static int pass_gen_present(uint64_t deadline) { } void gl_renderer_pass_service_presents(void) { + /* The transaction has restored guest/GPU state. Resume queued rendering + * before servicing presentation so emulation never owns its deadline wait. */ + if (s_rth_on && !rt_on_render_thread()) { + gl_rth_release(); + RTH_REC(RTH_PASS_SERVICE, 0, 0); + return; + } GL_RT_SYNC("pass_service_presents"); uint64_t deadline; double sp; @@ -9833,6 +9869,7 @@ static int s_pt_want = 0, s_pt_slots = 3; static GLenum s_pt_format = GL_RGBA8; /* the window's: no alpha -> GL_RGB8 */ static SDL_GLContext s_pt_ctx = NULL; static GLuint s_pts_tex[PT_MAX_SLOTS], s_pts_rb[PT_MAX_SLOTS], s_pts_fbo[PT_MAX_SLOTS]; +static uint64_t s_pts_deadline[PT_MAX_SLOTS]; static int s_pts_w[PT_MAX_SLOTS], s_pts_h[PT_MAX_SLOTS]; static unsigned s_pts_gen[PT_MAX_SLOTS]; /* present thread's own objects (FBOs are per-context) */ @@ -9944,6 +9981,7 @@ static void *pt_cb_present(void *user, int slot, void *ready) { GL_PRESENT_THREAD_TEST_HOOK(slot); #endif void *done = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); + interp_wait_until_ex(s_pts_deadline[slot], SDL_GetPerformanceFrequency(), 0); gl_window_swap(); return done; } @@ -10025,6 +10063,7 @@ static void gl_present_swap(void) { rthf_gpu_pause(); if (!s_pt_on) { gl_window_swap(); return; } (void)pt_target_fbo(); /* a frame that never bound 0 still has its slot */ + s_pts_deadline[pt_current()] = s_interp_pending_deadline; void *ready = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); glFlush(); void *done = NULL; @@ -11492,7 +11531,7 @@ static int gl_rth_eligible(void) { extern int psx_netplay_active(void); return s_raster_ok && s_ctx && !s_cpu_auth_dual && !s_depth24_skip_up && !(s_hd_native_authority && s_rth_a0_open) && - !gpu_display_is_depth24() && !s_interp_enabled && !s_pass_active && + !gpu_display_is_depth24() && !s_pass_active && !psx_netplay_active() && !psx_openxr_session_active() && gr_backend() == GR_BACKEND_OPENGL; } @@ -14285,6 +14324,7 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; if (c->op == RTH_FRAME) { rthf_end(); return; } + if (c->op == RTH_PASS_SERVICE) { gl_renderer_pass_service_presents(); return; } if (!s_rthf_open && (atomic_load_explicit(&s_rthf_want, memory_order_relaxed) || s_fg_on)) rthf_begin(); rthf_gpu_resume(); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 923c32f9..9abb3916 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -4045,6 +4045,7 @@ static int present_should_wall_pace(void) { /* The single-context temporal-blend path subdivides this same stock frame * interval. Do not run the outer pacer as well or guest timing doubles. */ if (g_frame_interpolation && g_gl_active && + !gl_renderer_render_thread_active() && gl_renderer_interpolation_owns_cadence()) return 0; /* Run the pacer even when driver vsync is SUPPOSED to own the cadence. @@ -4924,7 +4925,21 @@ static void input_replay_frontend_tick(uint64_t frame) { int bytes = gl_renderer_render_thread_json(renderer, sizeof renderer); if (bytes > 0 && bytes < (int)sizeof renderer) { std::ofstream detail(std::string(report) + ".renderer.json"); - detail << "{" << renderer << "}"; + char generation[8192]; + int generated_bytes = gl_renderer_frame_gen_json(generation, sizeof generation); + RenderPassStats rp{}; render_pass_get_stats(&rp); + detail << "{" << renderer << ",\"render_pass\":{\"passes\":" << rp.passes + << ",\"wanted\":" << rp.wanted << ",\"planned\":" << rp.planned + << ",\"refused\":" << rp.refused << ",\"aborted\":" << rp.aborted + << ",\"status_refused\":" << rp.status_refused << ",\"last_status\":" << rp.last_failure.status + << ",\"discarded\":" << rp.discarded << ",\"verify_checks\":" << rp.verify_checks + << ",\"verify_mismatch\":" << rp.verify_mismatch + << ",\"avg_pass_ms\":" << rp.avg_pass_ms << ",\"avg_guest_ms\":" << rp.avg_guest_ms + << ",\"avg_begin_ms\":" << rp.avg_begin_ms << ",\"avg_end_ms\":" << rp.avg_end_ms + << ",\"avg_restore_ms\":" << rp.avg_restore_ms << "}"; + if (generated_bytes > 0 && generated_bytes < (int)sizeof generation) + detail << ",\"frame_generation\":{" << generation << "}"; + detail << "}"; if (!detail) throw std::runtime_error("cannot write input replay renderer report"); } } @@ -9712,6 +9727,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const uint64_t waited = SDL_GetPerformanceCounter() - dyn_t0; if (g_dynres.active) g_dynres.pacer_ticks += waited; g_frame_report_wait_ticks += waited; + if (g_frame_interpolation) gl_renderer_pass_note_frontend_idle(waited); } runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); @@ -10296,8 +10312,7 @@ static void render_thread_vblank(void) { } } else { std::fprintf(stdout, "psxrecomp: render thread requested but not started " - "(needs the OpenGL backend without netplay, frame interpolation " - "or a 24-bit display)\n"); + "(needs the OpenGL backend without netplay or a 24-bit display)\n"); if (g_frame_generation) std::fprintf(stdout, "psxrecomp: Smooth motion (frame generation) needs the render thread; off\n"); } diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 7647c7f1..fc47f7b3 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -414,10 +414,6 @@ int psx_mod_render_view(const PSXModRenderView *view) { return 1; } -static int passes_allowed(void) { - return pass_status() == PSX_MOD_RENDER_PASS_READY; -} - uint32_t psx_mod_render_pass_status(void) { return pass_status(); } @@ -440,7 +436,14 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, if (!alpha_q16 || max == 0 || period_vblanks == 0 || period_vblanks > 8 || shown_after_vblanks > 8) return 0; - if (!passes_allowed()) return 0; + { + uint32_t status = pass_status(); + if (status != PSX_MOD_RENDER_PASS_READY) { + s_stats.status_refused++; + gl_renderer_pass_cancel_plan(); + return pass_refuse("plan_status", status); + } + } n = gl_renderer_pass_plan(period_vblanks, shown_after_vblanks, alpha_q16, max, &wanted); s_stats.wanted += wanted; diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c index 8b508347..afb80d79 100644 --- a/runtime/tests/test_frame_interpolation_schedule.c +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -284,6 +284,25 @@ int main(void) { &schedule, 0u, 1000000u, 60.0, 59.0), "output below source cadence should be rejected"); + frame_interpolation_schedule_reset(&schedule); + schedule.queued = 1; + for (int i = 1; i <= 6; ++i) { + uint64_t deadline; + float alpha; + CHECK(frame_interpolation_schedule_begin(&schedule, 1000000u, + 1000000u, 60.0, 60.0), "queued interval accepted"); + CHECK(frame_interpolation_schedule_next(&schedule, 1000000u, + &deadline, &alpha), "queued interval has one output"); + CHECK(fabs((double)deadline - (1000000.0 + i * 1000000.0 / 60.0)) < 1.0, + "early producer retains every presentation deadline"); + CHECK(!frame_interpolation_schedule_next(&schedule, 1000000u, + &deadline, &alpha), "queued interval has no duplicate output"); + } + CHECK(frame_interpolation_schedule_begin(&schedule, 2000000u, + 1000000u, 60.0, 60.0), "queued clock recovers after sustained stall"); + CHECK(frame_interpolation_schedule_end(&schedule) == 2016667u, + "late queued producer reanchors after sustained stall"); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); return failures ? 1 : 0; } diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 2adba0e5..889edbda 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -197,6 +197,7 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { } uint32_t gl_renderer_pass_leaks(void) { return 0; } int gl_renderer_pass_verify_vram(void) { return 1; } +void gl_renderer_pass_cancel_plan(void) {} void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} uint32_t g_psx_vblank_cycles = 564480u; diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 7fb4cc0d..63eef85e 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -254,6 +254,7 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { } uint32_t gl_renderer_pass_leaks(void) { return 0; } int gl_renderer_pass_verify_vram(void) { return 1; } +void gl_renderer_pass_cancel_plan(void) {} void gl_renderer_pass_note_cost(uint64_t t) { (void)t; } void gl_renderer_pass_service_presents(void) {} static uint32_t s_local_status = PSX_MOD_RENDER_PASS_READY;