diff --git a/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp index 3730a07c..cf0d6dc0 100644 --- a/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp +++ b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp @@ -91,11 +91,6 @@ #include "../render_hooks.h" -REXCVAR_DEFINE_BOOL(ac6_fps_physics_fix, true, "AC6", - "Make flight-model accel/decel framerate-independent when the FPS unlock " - "is active (rescales the game's fixed per-frame force steps by " - "frame time / 33.3ms)"); - namespace { // Longitudinal force/speed-command accumulator in the flight-model object @@ -112,12 +107,13 @@ constexpr double kNativeFrameMs = 1000.0 / 30.0; // wrappers are the sole callers), so plain statics are safe throughout. // Per-frame-step scale for this frame: 1.0 at the native 30fps cadence, -// shrinking as the frame rate rises (never above 1.0 - the game's own delta -// clamp already holds sim speed at/below 30fps). Gated on the SAME signal as -// the frame-delta hooks (ac6::TimingHooksActive) so the force step and the -// kinematics delta always revert to vanilla together (cutscene clamp, menus, -// unlock off). Returns exactly 1.0 for a pass-through. Inputs change at most -// once per guest frame, so the result is cached per frame. +// shrinking as the frame rate rises. Below 30fps it keeps growing (up to the +// ac6_min_sim_fps floor via ac6::ClampedMinSimFps) so the per-frame dynamics +// track the raised delta clamp instead of freezing at the 30fps step. Gated on +// the SAME signal as the frame-delta hooks (ac6::TimingHooksActive) so the +// dynamics and the kinematics delta always revert to vanilla together +// (cutscene clamp, menus, unlock off). Returns exactly 1.0 for a pass-through. +// Inputs change at most once per guest frame, so the result is cached. double StepRatio() { static uint64_t s_cached_frame = ~uint64_t(0); static double s_cached_ratio = 1.0; @@ -127,15 +123,18 @@ double StepRatio() { } s_cached_frame = stats.frame_count; s_cached_ratio = 1.0; - if (!REXCVAR_GET(ac6_fps_physics_fix) || !ac6::TimingHooksActive()) { + // Framerate-independence is part of the unlock; gate on the unlock alone + // (was the separate ac6_fps_physics_fix cvar, now folded in). + if (!ac6::TimingHooksActive()) { return s_cached_ratio; } if (stats.frame_time_ms <= 0.0) { return s_cached_ratio; // no frame measured yet } + const double ratio_cap = 30.0 / ac6::ClampedMinSimFps(); double ratio = stats.frame_time_ms / kNativeFrameMs; - if (ratio > 1.0) { - ratio = 1.0; // never blend past the native 30fps step + if (ratio > ratio_cap) { + ratio = ratio_cap; } else if (ratio < 0.02) { ratio = 0.02; // sanity floor against a bogus frame-time sample } diff --git a/src/main.cpp b/src/main.cpp index a90c54d6..5fc78164 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,7 +7,6 @@ #include REXCVAR_DECLARE(bool, ac6_render_capture); -REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled); REXCVAR_DECLARE(bool, ac6_unlock_fps); REXCVAR_DECLARE(bool, ac6_native_graphics_enabled); REXCVAR_DECLARE(bool, ac6_force_safe_draw_resolution_scale); @@ -218,7 +217,9 @@ std::unique_ptr Ac6recompAppCreate(rex::ui::WindowedAppCon if (!rex::cvar::HasNonDefaultValue("log_level")) { REXCVAR_SET(log_level, "debug"); } - REXCVAR_SET(ac6_unlock_fps, false); + // Smooth 60fps unlock is on by default now (zero-config for players); the + // toml can still set ac6_unlock_fps=false for stock locked behaviour. + REXCVAR_SET(ac6_unlock_fps, true); ApplyAc6DefaultSettings(); ApplyAc6HybridStartupSafetyOverrides(); ApplyAc6FixDefaults(); diff --git a/src/render_hooks.cpp b/src/render_hooks.cpp index cc3a5728..8ceb238d 100644 --- a/src/render_hooks.cpp +++ b/src/render_hooks.cpp @@ -6,14 +6,25 @@ #include #include +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + +#include #include #include #include #include -REXCVAR_DEFINE_BOOL(ac6_unlock_fps, false, "AC6", "Unlock frame rate to 60fps"); -REXCVAR_DEFINE_BOOL(ac6_timing_hooks_enabled, true, "AC6", - "Enable AC6 timing hooks that alter the game's presentation cadence"); +REXCVAR_DEFINE_BOOL(ac6_unlock_fps, true, "AC6", + "Master switch for the smooth 60fps unlock (modern present pacing + " + "dt-snap + physics dt-correction). On by default; false = stock behaviour."); REXCVAR_DEFINE_BOOL(ac6_cutscene_clamp, true, "AC6", "Suspend the 60fps unlock during in-engine cutscenes so they " "play at native ~30fps instead of double speed"); @@ -23,14 +34,90 @@ REXCVAR_DEFINE_BOOL(ac6_dynamic_vblank, true, "AC6", "free-runs at the configured rate. Gameplay is detected via the " "world-compositor draw heartbeat; cutscenes via the cinematic " "hooks."); -REXCVAR_DEFINE_BOOL(ac6_delta_precision, true, "AC6", - "With the FPS unlock active, carry the fractional remainder of the " - "game's integer frame delta across frames so its floor(x)+1 " - "truncation guard stops inflating game speed at high framerates " - "(+2% at 60fps, +10% at 300fps)"); +REXCVAR_DEFINE_DOUBLE(ac6_fps_target, 60.0, "AC6", + "The rate the simulation + presentation run at under the FPS unlock. " + ">0 = that exact rate (clamped to 30..ac6_max_sim_fps). " + "0 = AUTO: the largest rate <= ac6_max_sim_fps that evenly divides your " + "monitor's refresh, so frames land on refresh boundaries instead of " + "beating against them (240Hz->60, 144Hz->48, 120Hz->60, 60Hz->60). " + "Pick a target that divides your refresh; auto does this for you. " + "Mirrors PA's native PC engine, which has no separate cap and simply " + "runs the (dt-correct) sim at the display rate."); +REXCVAR_DEFINE_DOUBLE(ac6_max_sim_fps, 60.0, "AC6", + "Ceiling on the simulation/pacing rate. AC6's physics is only validated " + "to 60fps; above it, fixed-step assumptions surface (untested regime). " + "Both ac6_fps_target and the auto (refresh-matched) target are clamped to " + "this. Raise only once the >60 regime has been validated."); +REXCVAR_DEFINE_BOOL(ac6_dt_snap, true, "AC6", + "With the FPS unlock active, snap the per-frame simulation delta to the " + "EXACT pacing target when the real frame time is within ~1ms of it, so " + "the fixed-step integrator sees a constant step. Removes the residual " + "per-frame delta jitter that a fixed-step sim turns into visible shake; " + "genuine slowdowns fall through to the precision path."); +REXCVAR_DEFINE_DOUBLE(ac6_min_sim_fps, 20.0, "AC6", + "Lowest framerate the simulation runs at true speed before the " + "game's frame-delta clamp forces slow motion. The stock game floors " + "at 30 (below 30fps it plays in slow motion); this lowers the floor " + "(default 20) so a sub-30fps dip runs at correct speed - just " + "choppier - instead of slowing down and rubber-banding on recovery. " + "Set 30 for stock behavior. Active with the FPS unlock; drives the " + "frame-delta clamp and the physics step cap together so dynamics " + "stay consistent with the kinematics."); using Clock = std::chrono::steady_clock; +// Current monitor refresh rate in Hz, 0 if unknown. Cached on first use (a +// mid-session refresh change is rare enough to need a restart). +static double HostRefreshHz() { + static double cached = -1.0; + if (cached >= 0.0) { + return cached; + } + cached = 0.0; +#if defined(_WIN32) + DEVMODEW dm; + ZeroMemory(&dm, sizeof(dm)); + dm.dmSize = sizeof(dm); + if (EnumDisplaySettingsW(nullptr, ENUM_CURRENT_SETTINGS, &dm) && dm.dmDisplayFrequency > 1) { + cached = double(dm.dmDisplayFrequency); + } +#endif + return cached; +} + +// Single source of truth for the sim/presentation rate under the unlock, so the +// pacing limiter and the dt-snap always agree. See ac6_fps_target / ac6_max_sim_fps. +static double ResolvePacingTargetFps() { + // Absolute floor (10) lets sub-30 rates be targeted for testing; below + // ac6_min_sim_fps the sim goes slow-mo (expected), but the rate is honored. + constexpr double kMinTargetFps = 10.0; + double ceil_fps = REXCVAR_GET(ac6_max_sim_fps); + if (ceil_fps < kMinTargetFps) ceil_fps = kMinTargetFps; + if (ceil_fps > 240.0) ceil_fps = 240.0; + + double target = REXCVAR_GET(ac6_fps_target); + if (target > 0.0) { + if (target < kMinTargetFps) target = kMinTargetFps; + if (target > ceil_fps) target = ceil_fps; + return target; + } + + // Auto: largest integer in [kMinTargetFps, ceil] that evenly divides the + // refresh rate, so frames align to refresh boundaries instead of beating. + const double refresh = HostRefreshHz(); + if (refresh < kMinTargetFps) { + return ceil_fps; // unknown refresh: just run at the ceiling + } + const int hz = int(refresh + 0.5); + for (int f = int(ceil_fps + 0.5); f >= int(kMinTargetFps); --f) { + if (hz % f == 0) { + return double(f); + } + } + // No clean divisor >= 30 (e.g. 50/75Hz): fall back to min(refresh, ceiling). + return refresh < ceil_fps ? refresh : ceil_fps; +} + namespace { std::atomic g_frame_time_ms{0.0}; @@ -77,7 +164,7 @@ bool AreTimingHooksActive() { // to keep menus/hangar at native cadence, and it fails closed (a game // build/render path whose compositor shader hashes differently would never // stamp it). ac6_dynamic_vblank=false restores the plain always-on unlock. - return REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps) && + return REXCVAR_GET(ac6_unlock_fps) && !ac6::IsCinematicActive() && (!REXCVAR_GET(ac6_dynamic_vblank) || IsWorldRenderActive()); } @@ -94,6 +181,16 @@ bool TimingHooksActive() { return AreTimingHooksActive(); } +double ClampedMinSimFps() { + double min_fps = REXCVAR_GET(ac6_min_sim_fps); + if (min_fps < 10.0) { + min_fps = 10.0; + } else if (min_fps > 30.0) { + min_fps = 30.0; // can't floor above 30fps - that would slow 30fps content + } + return min_fps; +} + bool WorldRenderActiveRecently() { return IsWorldRenderActive(); } @@ -147,29 +244,60 @@ void ac6DeltaPrecisionHook(PPCRegister& r8, PPCRegister& r10, PPCRegister& r29, s_remainder = 0.0; return; } - if (!REXCVAR_GET(ac6_delta_precision)) { + const bool precision = true; // always on now (frame-delta correctness) + const double min_fps = ac6::ClampedMinSimFps(); + const bool raise_floor = min_fps < 30.0; + if (!precision && !raise_floor) { s_remainder = 0.0; return; } - const double max_delta = 100.0; // the game's stock 30fps delta clamp + // The delta clamp becomes 3000/min_fps instead of the stock 100 (30fps->100, + // 20fps->150): the sim keeps true speed down to min_fps before slow motion. + const double max_delta = 3000.0 / min_fps; // Exact delta this frame in the game's own scale (elapsed * 3000), using the - // same ticks-per-frame divisor (r10 = freq / 30) the game divided by. + // same ticks-per-frame divisor (r10 = freq / 30) the game divided by. Clamp + // to the floor: time slower than min_fps is dropped, not banked. double exact = double(r30.u32) * 100.0 / double(r10.u32); if (exact > max_delta) { exact = max_delta; } - // Carry the remainder (no +1) so the summed integer deltas track real time. - const double base = s_remainder + exact; + // dt-snap: at a steady framerate near the pacing target, force the delta to the + // EXACT target value so the fixed-step integrator sees a constant step. This is + // what removes the residual per-frame jitter (the aircraft shake) that survives + // smooth pacing - a fixed-step sim amplifies even sub-millisecond dt variance. + // Only engages within a tight window of the target; genuine slowdowns/speedups + // fall through to the precision path below. Mirrors PA's native PC engine, which + // feeds a constant dt at a steady rate. The target matches the pacing limiter + // exactly (shared ResolvePacingTargetFps), so sim and presentation stay locked. + if (REXCVAR_GET(ac6_dt_snap)) { + const double target_fps = ResolvePacingTargetFps(); + const double target_delta = 3000.0 / target_fps; // game units, 100 = 30fps + const double tol_units = 3.0; // ~1ms window (100 delta-units = 33.3ms, so 1ms = 3) + if (tol_units > 0.0 && target_delta <= max_delta && + std::fabs(exact - target_delta) < tol_units) { + double snapped = std::floor(target_delta + 0.5); + if (snapped < 1.0) { + snapped = 1.0; + } + s_remainder = 0.0; // locked to target; no fractional carry + r8.u64 = uint64_t(snapped); + return; + } + } + + // precision on: carry the remainder (no +1). precision off: keep the game's + // floor(exact)+1 integer. floor(exact + 1) == floor(exact) + 1. + const double base = precision ? (s_remainder + exact) : (exact + 1.0); double delta = std::floor(base); if (delta < 1.0) { - delta = 1.0; // the game guarantees progress every frame; the overshoot is - // repaid through a negative remainder next frame + delta = 1.0; // the game guarantees progress every frame; on the precision + // path the overshoot is repaid through a negative remainder } else if (delta > max_delta) { delta = max_delta; } - s_remainder = base - delta; + s_remainder = precision ? (base - delta) : 0.0; r8.u64 = uint64_t(delta); } @@ -180,19 +308,35 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) { // frame-locked content (menus, hangar, cutscenes) at the native 60Hz. Only // engages when the FPS unlock is on, so default configurations keep the plain // cvar-driven vblank behavior. - const bool unlock = REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps); + const bool unlock = REXCVAR_GET(ac6_unlock_fps); const bool dynamic_pacing = REXCVAR_GET(ac6_dynamic_vblank) && unlock; // Single source of truth for "the unlock is remapping the cadence right now" - // the same signal that gates the interval/delta hooks and the physics rescale. const bool free_running = dynamic_pacing && AreTimingHooksActive(); - // Guest-vblank Hz override for this frame. 0 = no override (free-run at the - // vsync/tearing rate); dynamic pacing forces frame-locked content to 60Hz. + // Frame pacing for this frame. Precedence: + // 1. frame-locked content (menus, cutscenes) -> fixed 60Hz vblank override; + // 2. gameplay with ac6_vblank_auto -> modern present pacing: the guest + // vblank free-runs (gates nothing) and the game's swap thread instead + // blocks on real GPU delivery plus an absolute-deadline limiter at + // ac6_fps_target. The game runs at min(target, real GPU throughput) + // with uniform frame times - no vblank grid, so a sub-target GPU can + // never alias into the 60/30 sub-harmonic staircase; + // 3. otherwise the plain cvar-driven vblank (previous behavior). double override_hz = 0.0; + double pacing_target_hz = 0.0; if (dynamic_pacing && !free_running) { override_hz = 60.0; + } else if (free_running) { + // Force the guest vblank to free-run (non-gating) during gameplay so + // PaceGuestPresent is the SOLE gate, regardless of the vsync / + // guest_vblank_sync_to_refresh cvars (whose defaults would otherwise re-add + // a 60Hz gate). This makes the smooth unlock work with zero configuration. + override_hz = 1000.0; + pacing_target_hz = ResolvePacingTargetFps(); } rex::graphics::GraphicsSystem::SetGuestVblankHzOverride(override_hz); + rex::graphics::GraphicsSystem::SetGuestPresentPacing(pacing_target_hz); const auto now = Clock::now(); if (g_frame_start.time_since_epoch().count() != 0) { @@ -205,18 +349,23 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) { } g_frame_start = now; - // Log the first handful of pacing transitions. + // Log the first handful of pacing transitions (smooth pacing keyed negative + // so mode switches log distinctly from plain overrides). + const double log_key = pacing_target_hz > 0.0 ? -pacing_target_hz : override_hz; static double last_log_key = -0.5; static uint32_t transition_logs = 0; - if (unlock && override_hz != last_log_key && transition_logs < 32) { + if (unlock && log_key != last_log_key && transition_logs < 32) { ++transition_logs; - if (override_hz == 0.0) { + if (pacing_target_hz > 0.0) { + REXLOG_INFO("[AC6-VBLANK] pacing -> modern (present-paced, target {:.0f}fps)", + pacing_target_hz); + } else if (override_hz == 0.0) { REXLOG_INFO("[AC6-VBLANK] pacing -> free-run (uncapped)"); } else { REXLOG_INFO("[AC6-VBLANK] pacing -> {:.0f}Hz guest vblank", override_hz); } } - last_log_key = override_hz; + last_log_key = log_key; } void ac6CinematicTickHook(PPCRegister& /*r3*/) { diff --git a/src/render_hooks.h b/src/render_hooks.h index 4b57a83b..a17e218a 100644 --- a/src/render_hooks.h +++ b/src/render_hooks.h @@ -6,7 +6,6 @@ #include REXCVAR_DECLARE(bool, ac6_unlock_fps); -REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled); REXCVAR_DECLARE(bool, ac6_cutscene_clamp); namespace ac6 { @@ -24,6 +23,12 @@ FrameStats GetFrameStats(); // off this so it stays in lockstep with the frame-delta hooks. bool TimingHooksActive(); +// ac6_min_sim_fps clamped to its supported range [10, 30]. Single source for +// the low-fps floor shared by the frame-delta clamp (ac6DeltaPrecisionHook) +// and the physics step cap (StepRatio) - the two must saturate at the same +// framerate or the dynamics desync from the kinematics below 30fps. +double ClampedMinSimFps(); + // True while an in-engine cutscene (NU::FW::IngameCinematics, driven by // CAce6DemoManager::Exec) has ticked within the last decay window. Used by the // timing hooks to suspend the 60fps unlock so cutscenes play at native cadence. diff --git a/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h b/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h index e03c9a18..d030f7f9 100644 --- a/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h +++ b/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h @@ -109,6 +109,23 @@ class GraphicsSystem : public system::IGraphicsSystem { static void SetGuestVblankHzOverride(double hz); static double GetGuestVblankHzOverride(); + // Modern present pacing: while target_hz > 0, PaceGuestPresent (called by + // VdSwap on the guest thread that submits swaps) blocks that thread until + // every previously issued swap has been delivered to the presenter (GPU + // backpressure, frame latency 1), then rate-limits it to target_hz with an + // absolute-deadline limiter. The game then runs at min(target, real GPU + // throughput) with uniform frame times - the modern game loop - instead of + // quantizing to vblank-grid sub-harmonics (60/30/20) when the GPU cannot + // hold the target. Combine with a free-running guest vblank so the vblank + // wait never gates. target_hz 0 = off. Process-wide, not per-instance. + static void SetGuestPresentPacing(double target_hz); + // Called by VdSwap on the swapping guest thread before it submits the next + // swap; blocks per SetGuestPresentPacing. No-op while pacing is off. + static void PaceGuestPresent(); + // Called by the command processor whenever a guest frame is actually + // delivered to the presenter (guest output refreshed for a swap). + static void NotifyGuestPresent(); + bool Save(::rex::stream::ByteStream* stream); bool Restore(::rex::stream::ByteStream* stream); diff --git a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp index f043454f..d88d7437 100644 --- a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp @@ -2582,6 +2582,12 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, return true; }); + if (refreshed) { + // A guest frame was actually delivered to the presenter - feed the + // delivery-paced present pacing (see GraphicsSystem::NotifyGuestPresent). + GraphicsSystem::NotifyGuestPresent(); + } + // End the frame even if did not present for any reason (the image refresher // was not called), to prevent leaking per-frame resources. REXGPU_DEBUG("IssueSwap: post-RefreshGuestOutput EndSubmission"); diff --git a/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp b/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp index 56506d7d..e1916e69 100644 --- a/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp @@ -13,10 +13,13 @@ #include #include +#include +#include #include #include #include #include +#include #include #include @@ -113,6 +116,23 @@ __declspec(dllexport) uint32_t AmdPowerXpressRequestHighPerformance = 1; } // extern "C" #endif // REX_PLATFORM_WIN32 +namespace { +// Modern present pacing state (see SetGuestPresentPacing / PaceGuestPresent). +// The counters pair the guest's swap submissions (VdSwap) with the frames the +// command processor actually delivered to the presenter (NotifyGuestPresent). +// PaceGuestPresent blocks the swapping guest thread on that pairing plus an +// absolute-deadline frame limiter - GPU backpressure and a rate ceiling, the +// modern game loop - instead of pacing the guest off the vblank grid. +std::atomic g_present_pacing_target_hz{0.0}; +std::atomic g_guest_present_count{0}; +std::atomic g_guest_swaps_issued{0}; +std::mutex g_present_pacing_mutex; +std::condition_variable g_present_pacing_cv; +// Guarded by g_present_pacing_mutex. +std::chrono::steady_clock::time_point g_present_pacing_deadline{}; +uint64_t g_present_delivery_at_last_timeout = UINT64_MAX; +} // namespace + GraphicsSystem::GraphicsSystem() : vsync_worker_running_(false) {} GraphicsSystem::~GraphicsSystem() = default; @@ -423,6 +443,91 @@ double GraphicsSystem::GetGuestVblankHzOverride() { return g_guest_vblank_hz_override.load(std::memory_order_relaxed); } +void GraphicsSystem::SetGuestPresentPacing(double target_hz) { + g_present_pacing_target_hz.store(target_hz, std::memory_order_relaxed); +} + +void GraphicsSystem::NotifyGuestPresent() { + g_guest_present_count.fetch_add(1, std::memory_order_relaxed); + // Empty critical section closes the race with PaceGuestPresent's predicate + // check-then-wait, so the notify below cannot fall between them and be lost. + { std::lock_guard lock(g_present_pacing_mutex); } + g_present_pacing_cv.notify_all(); +} + +void GraphicsSystem::PaceGuestPresent() { + const double target_hz = g_present_pacing_target_hz.load(std::memory_order_relaxed); + if (target_hz <= 0.0) { + return; + } + + // 1) Backpressure: before letting the game submit the next swap, wait until + // every previously issued swap has been delivered to the presenter (frame + // latency 1). This is what paces the game to the real GPU rate when it + // cannot hold target_hz - uniformly, with no vblank grid to alias against. + const uint64_t issued = g_guest_swaps_issued.load(std::memory_order_relaxed); + { + std::unique_lock lock(g_present_pacing_mutex); + const auto pred = [&] { + return g_guest_present_count.load(std::memory_order_relaxed) >= issued; + }; + const uint64_t delivered_now = g_guest_present_count.load(std::memory_order_relaxed); + // While delivery is known-stalled (swaps are not reaching the presenter - + // device loss, experimental swap paths), skip the wait entirely; the + // limiter below still runs, degrading this to a plain frame-rate cap. + const bool delivery_stalled = g_present_delivery_at_last_timeout != UINT64_MAX && + delivered_now == g_present_delivery_at_last_timeout; + if (!delivery_stalled && !pred()) { + if (g_present_pacing_cv.wait_for(lock, std::chrono::milliseconds(100), pred)) { + g_present_delivery_at_last_timeout = UINT64_MAX; + } else { + // Timed out: forgive the whole backlog so one undelivered swap cannot + // park every future frame on this timeout. + g_present_delivery_at_last_timeout = + g_guest_present_count.load(std::memory_order_relaxed); + g_guest_swaps_issued.store(g_present_delivery_at_last_timeout, + std::memory_order_relaxed); + } + } else if (!delivery_stalled) { + g_present_delivery_at_last_timeout = UINT64_MAX; + } + } + g_guest_swaps_issued.fetch_add(1, std::memory_order_relaxed); + + // 2) Ceiling: absolute-deadline limiter at target_hz. Deadlines advance by + // exactly one interval while the game keeps up (drift-free target rate) and + // re-anchor to now when it does not, so a slow stretch accrues no catch-up + // debt that would burst frames afterwards. + const auto interval = std::chrono::duration_cast( + std::chrono::duration(1.0 / target_hz)); + auto now = std::chrono::steady_clock::now(); + std::chrono::steady_clock::time_point wake; + { + std::lock_guard lock(g_present_pacing_mutex); + if (g_present_pacing_deadline < now) { + g_present_pacing_deadline = now; + } + wake = g_present_pacing_deadline; + g_present_pacing_deadline = wake + interval; + } + // Coarse sleep to ~2ms short of the deadline, then spin the remainder for + // precision (OS sleep granularity is ~1ms and can overshoot). + while (true) { + now = std::chrono::steady_clock::now(); + if (now >= wake) { + break; + } + const auto remaining = wake - now; + if (remaining > std::chrono::milliseconds(2)) { + rex::thread::Sleep( + std::chrono::duration_cast(remaining) - + std::chrono::milliseconds(2)); + } else { + std::this_thread::yield(); + } + } +} + void GraphicsSystem::MarkVblank() { // TODO: Enable profiling once ported // SCOPE_profile_cpu_f("gpu"); diff --git a/thirdparty/rexglue-sdk/src/kernel/xboxkrnl/xboxkrnl_video.cpp b/thirdparty/rexglue-sdk/src/kernel/xboxkrnl/xboxkrnl_video.cpp index baacaab4..f50ea25b 100644 --- a/thirdparty/rexglue-sdk/src/kernel/xboxkrnl/xboxkrnl_video.cpp +++ b/thirdparty/rexglue-sdk/src/kernel/xboxkrnl/xboxkrnl_video.cpp @@ -430,6 +430,11 @@ void VdSwap_entry(ppc_pvoid_t buffer_ptr, // ptr into primary ringbuffer assert(width); assert(height); + // Modern present pacing (no-op unless enabled): block this guest thread on + // GPU delivery of the previous swap plus the target-rate limiter before the + // game may submit the next one. See GraphicsSystem::PaceGuestPresent. + rex::graphics::GraphicsSystem::PaceGuestPresent(); + namespace xenos = rex::graphics::xenos; xenos::xe_gpu_texture_fetch_t gpu_fetch;