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/ac6recomp_app.h b/src/ac6recomp_app.h index 58c1f05c..9005ac39 100644 --- a/src/ac6recomp_app.h +++ b/src/ac6recomp_app.h @@ -15,6 +15,7 @@ #include "ac6_native_graphics.h" #include "ac6_native_graphics_overlay.h" +#include "render_hooks.h" #include "generated/ac6recomp_config.h" REXCVAR_DECLARE(std::string, ac6_graphics_backend); @@ -78,6 +79,17 @@ class Ac6recompApp : public rex::ReXApp { native_graphics_status_dialog_ = std::make_unique(drawer); native_graphics_status_dialog_->Show(); + + // Feed the F3 debug overlay the game's own frame stats so its guest + // frametime graph and counter reflect the real sim cadence (SDK cannot + // reach into the AC6 app layer, so it takes them through this callback). + // Must be here, not OnPostSetup: the debug overlay is created just before + // OnCreateDialogs, but OnPostSetup runs BEFORE the overlay exists, so a + // provider set there is silently dropped. + SetGuestFrameStats([]() -> rex::ui::FrameStats { + const ::ac6::FrameStats s = ::ac6::GetFrameStats(); + return rex::ui::FrameStats{s.frame_time_ms, s.fps, s.frame_count}; + }); } private: 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/CMakeLists.txt b/thirdparty/rexglue-sdk/CMakeLists.txt index 9d3196f1..2c5c997f 100644 --- a/thirdparty/rexglue-sdk/CMakeLists.txt +++ b/thirdparty/rexglue-sdk/CMakeLists.txt @@ -136,6 +136,17 @@ add_compile_definitions(REXGLUE_BUILD_CONFIG="$") # Generate version header -- must run after REX_PLATFORM is set # Timestamp is set at configure time -- intentionally not updated on every rebuild string(TIMESTAMP REXGLUE_BUILD_TIMESTAMP "%Y%m%d_%H%M") +# Short git commit hash of the containing project (re-run cmake to refresh it). +execute_process( + COMMAND git rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE REXGLUE_GIT_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NOT REXGLUE_GIT_HASH) + set(REXGLUE_GIT_HASH "nogit") +endif() configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/include/rex/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/include/rex/version.h 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/include/rex/rex_app.h b/thirdparty/rexglue-sdk/include/rex/rex_app.h index 6fcda846..4aa041f3 100644 --- a/thirdparty/rexglue-sdk/include/rex/rex_app.h +++ b/thirdparty/rexglue-sdk/include/rex/rex_app.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -139,6 +140,7 @@ class ReXApp : public ui::WindowedApp, public ui::WindowListener, public ui::Win // Built-in overlays std::shared_ptr log_sink_; std::unique_ptr debug_overlay_; + std::unique_ptr build_stamp_overlay_; std::unique_ptr console_overlay_; std::unique_ptr settings_overlay_; }; diff --git a/thirdparty/rexglue-sdk/include/rex/ui/overlay/build_stamp_overlay.h b/thirdparty/rexglue-sdk/include/rex/ui/overlay/build_stamp_overlay.h new file mode 100644 index 00000000..3fad9b77 --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/ui/overlay/build_stamp_overlay.h @@ -0,0 +1,26 @@ +/** + * @file rex/ui/overlay/build_stamp_overlay.h + * @brief Always-on build-stamp watermark (short git commit hash) drawn in + * the lower-left corner, independent of the F3 debug overlay. + * + * @copyright Copyright (c) 2026 Project Gracemeria + * @license BSD 3-Clause License + */ +#pragma once +#include + +namespace rex::ui { + +// A non-interactive dialog that draws the build's short git commit hash in the +// lower-left corner every frame. Registers itself with the ImGuiDrawer on +// construction (like any ImGuiDialog). Drawing is gated by the show_build_stamp +// cvar (on by default); set it false to hide the watermark. +class BuildStampOverlay : public ImGuiDialog { + public: + explicit BuildStampOverlay(ImGuiDrawer* imgui_drawer); + + protected: + void OnDraw(ImGuiIO& io) override; +}; + +} // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h b/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h index 2ce520d2..86de1a88 100644 --- a/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h +++ b/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h @@ -39,6 +39,44 @@ class DebugOverlayDialog : public ImGuiDialog { private: bool visible_ = false; FrameStatsProvider stats_provider_; + + // Frametime graph + throttled counter state. + static constexpr int kHistory = 240; + static constexpr double kCounterRefreshSeconds = 0.5; + + // Ring buffers of frame times in milliseconds. Host = the presenter/paint + // cadence (what an external tool like RTSS would measure); guest = the + // game's own per-frame sim delta (where framerate-dependent judder lives). + float host_ms_history_[kHistory] = {}; + float guest_ms_history_[kHistory] = {}; + int host_history_pos_ = 0; + int guest_history_pos_ = 0; + uint64_t last_guest_frame_count_ = 0; + + // Graph samples are pushed at a FIXED TIME interval (not per frame), so the + // graphs scroll at a consistent rate regardless of fps. kHistory samples then + // cover a fixed time window (240 * 1/60 s = ~4 s). Each sample is the max + // frametime since the last sample, so spikes between samples are preserved. + static constexpr double kGraphSampleSeconds = 1.0 / 60.0; + double graph_sample_accum_s_ = 0.0; + float graph_host_ms_max_ = 0.0f; + float graph_guest_ms_max_ = 0.0f; + + // Counter averaging window (refreshed every kCounterRefreshSeconds so the + // numbers are readable instead of flickering every frame). + double counter_window_s_ = 0.0; + int host_frames_in_window_ = 0; + uint64_t guest_frames_at_window_start_ = 0; + double window_guest_ms_min_ = 1.0e9; + double window_guest_ms_max_ = 0.0; + + // Held (displayed) counter values. + double disp_host_fps_ = 0.0; + double disp_host_ms_ = 0.0; + double disp_guest_fps_ = 0.0; + double disp_guest_ms_ = 0.0; + double disp_guest_ms_min_ = 0.0; + double disp_guest_ms_max_ = 0.0; }; } // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h b/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h new file mode 100644 index 00000000..e16ea9be --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h @@ -0,0 +1,20 @@ +/** + * @file rex/ui/present_stats.h + * @brief Lightweight presenter timing readouts for the debug overlay. + * + * @copyright Copyright (c) 2026 Project Gracemeria + * @license BSD 3-Clause License + */ +#pragma once + +namespace rex::ui { + +// Duration (ms) of the presenter's last swap-chain frame-latency wait - how long +// painting blocked on the GPU/display being ready to accept a new frame. A high, +// steady value means GPU-bound (the display/GPU is the bottleneck); ~0 means the +// pacing/limiter is the bottleneck (CPU-side), which is the healthy case. This is +// the analog of UnleashedRecomp's "Present Wait" profiler. +double GetLastPresentWaitMs(); +void SetLastPresentWaitMs(double ms); + +} // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/include/rex/version.h.in b/thirdparty/rexglue-sdk/include/rex/version.h.in index 8600b5d9..8538b50e 100644 --- a/thirdparty/rexglue-sdk/include/rex/version.h.in +++ b/thirdparty/rexglue-sdk/include/rex/version.h.in @@ -8,6 +8,7 @@ #define REXGLUE_BUILD_PLATFORM "@REX_PLATFORM@" #define REXGLUE_BUILD_TIMESTAMP "@REXGLUE_BUILD_TIMESTAMP@" +#define REXGLUE_GIT_HASH "@REXGLUE_GIT_HASH@" // Requires REXGLUE_BUILD_CONFIG compile definition // Title: "[rexglue-v0.7.1-Release]" @@ -18,3 +19,7 @@ #define REXGLUE_BUILD_STAMP \ "build: rexglue-v" REXGLUE_VERSION_STRING "-" REXGLUE_BUILD_PLATFORM \ "-" REXGLUE_BUILD_CONFIG "@" REXGLUE_BUILD_TIMESTAMP + +// Watermark: "rexglue-v0.7.1-Release @5120188b" (short git commit hash) +#define REXGLUE_BUILD_HASH_STAMP \ + "rexglue-v" REXGLUE_VERSION_STRING "-" REXGLUE_BUILD_CONFIG " @" REXGLUE_GIT_HASH diff --git a/thirdparty/rexglue-sdk/src/core/logging.cpp b/thirdparty/rexglue-sdk/src/core/logging.cpp index a91a38c8..85c7d82a 100644 --- a/thirdparty/rexglue-sdk/src/core/logging.cpp +++ b/thirdparty/rexglue-sdk/src/core/logging.cpp @@ -38,6 +38,12 @@ REXCVAR_DEFINE_STRING(log_level, "info", "Log", REXCVAR_DEFINE_STRING(log_file, "", "Log", "Log file path (empty = auto sequential naming)"); +REXCVAR_DEFINE_BOOL(log_new_file_per_launch, true, "Log", + "Start a fresh log each launch: delete the previous log_file (and its " + "rotations) so it is replaced, not appended-to, and the files don't stack " + "up. Only affects a fixed log_file name; the empty-log_file sequential path " + "is already per-launch."); + REXCVAR_DEFINE_BOOL(log_verbose, false, "Log", "Enable verbose logging (sets level to trace)") .debug_only(); @@ -69,6 +75,20 @@ bool g_initialized = false; std::mutex g_mutex; LogConfig g_config; +// Delete the previous log (and its rotation siblings ac6recomp.1.log, .2.log, ...) +// so each launch starts one FRESH log file, replacing the old one, instead of +// appending across runs. Run once at logging init before the sink is created. +static void RemovePreviousLog(const std::filesystem::path& path, int max_files) { + std::error_code ec; + std::filesystem::remove(path, ec); + const std::filesystem::path dir = path.parent_path(); + const std::string stem = path.stem().string(); + const std::string ext = path.extension().string(); + for (int i = 1; i <= max_files && i <= 64; ++i) { + std::filesystem::remove(dir / fmt::format("{}.{}{}", stem, i, ext), ec); + } +} + std::filesystem::path NextSequentialLogPath(const std::filesystem::path& logs_dir, std::string_view app_name) { std::filesystem::create_directories(logs_dir); @@ -219,6 +239,11 @@ void InitLogging(const LogConfig& config) { std::string resolved_path; if (config.log_file) { resolved_path = config.log_file; + // Fresh single log each launch (QoL): delete the previous one first so a + // fixed log_file is replaced, not appended-to, and files don't stack up. + if (!resolved_path.empty() && REXCVAR_GET(log_new_file_per_launch)) { + RemovePreviousLog(resolved_path, REXCVAR_GET(log_max_files)); + } } else if (!config.app_name.empty()) { auto log_dir = config.log_dir.empty() ? std::filesystem::current_path() / "logs" : std::filesystem::path(config.log_dir); diff --git a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp index 7afd000e..d88d7437 100644 --- a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp @@ -2322,7 +2322,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, // frame (will close the frame after this anyway, so can't write // multiple times per frame). - REXGPU_ERROR("RefreshGuestOutput: checkpoint 1 (gamma block start)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 1 (gamma block start)"); if (!(use_pwl_gamma_ramp ? gamma_ramp_pwl_up_to_date_ : gamma_ramp_256_entry_table_up_to_date_)) { uint32_t gamma_ramp_offset_bytes = use_pwl_gamma_ramp ? 256 * 4 : 0; @@ -2377,7 +2377,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, : gamma_ramp_256_entry_table_up_to_date_) = true; } - REXGPU_ERROR("RefreshGuestOutput: checkpoint 2 (descriptor heap allocation)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 2 (descriptor heap allocation)"); // Destination, source, and if bindful, gamma ramp. ui::d3d12::util::DescriptorCpuGpuHandlePair apply_gamma_descriptors[3]; ui::d3d12::util::DescriptorCpuGpuHandlePair apply_gamma_descriptor_gamma_ramp; @@ -2423,16 +2423,16 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, apply_gamma_dest_uav_desc.Texture2D.MipSlice = 0; apply_gamma_dest_uav_desc.Texture2D.PlaneSlice = 0; - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3 (CreateUAV, CreateSRV)"); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.1 - CreateUAV"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3 (CreateUAV, CreateSRV)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.1 - CreateUAV"); device->CreateUnorderedAccessView(apply_gamma_dest, nullptr, &apply_gamma_dest_uav_desc, apply_gamma_descriptors[0].first); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.2 - CreateSRV"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.2 - CreateSRV"); device->CreateShaderResourceView(swap_texture_resource, &swap_texture_srv_desc, apply_gamma_descriptors[1].first); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.3 - PushTransitionBarrier"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.3 - PushTransitionBarrier"); if (using_native_swap_texture) { PushTransitionBarrier(swap_texture_resource, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, @@ -2442,10 +2442,10 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE); gamma_ramp_buffer_state_ = D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE; - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.4 - D3DSetComputeRootSignature"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.4 - D3DSetComputeRootSignature"); deferred_command_list_.D3DSetComputeRootSignature(apply_gamma_root_signature_.Get()); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.5 - ApplyGammaConstants"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.5 - ApplyGammaConstants"); ApplyGammaConstants apply_gamma_constants; apply_gamma_constants.size[0] = guest_output_width; apply_gamma_constants.size[1] = guest_output_height; @@ -2453,7 +2453,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, UINT(ApplyGammaRootParameter::kConstants), sizeof(apply_gamma_constants) / sizeof(uint32_t), &apply_gamma_constants, 0); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.6 - RootDescriptorTable dest/src/ramp"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.6 - RootDescriptorTable dest/src/ramp"); deferred_command_list_.D3DSetComputeRootDescriptorTable( UINT(ApplyGammaRootParameter::kDestination), apply_gamma_descriptors[0].second); deferred_command_list_.D3DSetComputeRootDescriptorTable( @@ -2461,7 +2461,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, deferred_command_list_.D3DSetComputeRootDescriptorTable( UINT(ApplyGammaRootParameter::kRamp), apply_gamma_descriptor_gamma_ramp.second); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.7 - Select pipeline (pwl={} fxaa={})", use_pwl_gamma_ramp, use_fxaa); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.7 - Select pipeline (pwl={} fxaa={})", use_pwl_gamma_ramp, use_fxaa); ID3D12PipelineState* apply_gamma_pipeline; if (use_pwl_gamma_ramp) { apply_gamma_pipeline = use_fxaa ? apply_gamma_pwl_fxaa_luma_pipeline_.Get() @@ -2475,17 +2475,17 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, REXGPU_ERROR("RefreshGuestOutput: CRITICAL: apply_gamma_pipeline IS NULL!"); } - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.8 - SetExternalPipeline (ptr={:016X})", (uint64_t)apply_gamma_pipeline); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.8 - SetExternalPipeline (ptr={:016X})", (uint64_t)apply_gamma_pipeline); SetExternalPipeline(apply_gamma_pipeline); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.9 - SubmitBarriers"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.9 - SubmitBarriers"); SubmitBarriers(); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.10 - D3DDispatch"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.10 - D3DDispatch"); uint32_t group_count_x = (guest_output_width + 15) / 16; uint32_t group_count_y = (guest_output_height + 7) / 8; deferred_command_list_.D3DDispatch(group_count_x, group_count_y, 1); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 4 (post-dispatch)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 4 (post-dispatch)"); // Apply FXAA. if (use_fxaa) { @@ -2558,7 +2558,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, } else { if (apply_gamma_dest_initial_state != ui::d3d12::D3D12Presenter::kGuestOutputInternalState) { - REXGPU_ERROR("RefreshGuestOutput: checkpoint 5 - WARNING: unexpected apply_gamma_dest_initial_state {:08X}, expected {:08X}", + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 5 - WARNING: unexpected apply_gamma_dest_initial_state {:08X}, expected {:08X}", uint32_t(apply_gamma_dest_initial_state), uint32_t(ui::d3d12::D3D12Presenter::kGuestOutputInternalState)); } @@ -2574,19 +2574,25 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr, D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); } - REXGPU_ERROR("RefreshGuestOutput: checkpoint 6 (SubmitBarriers pre-EndSubmission)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 6 (SubmitBarriers pre-EndSubmission)"); SubmitBarriers(); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 7 (EndSubmission)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 7 (EndSubmission)"); EndSubmission(true); - REXGPU_ERROR("RefreshGuestOutput: checkpoint 8 (return true)"); + REXGPU_DEBUG("RefreshGuestOutput: checkpoint 8 (return true)"); 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_ERROR("IssueSwap: post-RefreshGuestOutput EndSubmission"); + REXGPU_DEBUG("IssueSwap: post-RefreshGuestOutput EndSubmission"); EndSubmission(true); - REXGPU_ERROR("IssueSwap: complete"); + REXGPU_DEBUG("IssueSwap: complete"); return refreshed; } 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/graphics/util/draw.cpp b/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp index 5eea7ad4..1117ba00 100644 --- a/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/util/draw.cpp @@ -1029,7 +1029,9 @@ bool GetResolveInfo(const RegisterFile& regs, const memory::Memory& memory, } if (x0 >= x1 || y0 >= y1) { - REXGPU_WARN( + // Empty resolve region after clamp is normal (many guest resolves scissor to + // nothing); debug-level so it doesn't flood the log every frame. + REXGPU_DEBUG( "Skipping empty resolve region after clamp/alignment: " "scissored {} <= x < {}, {} <= y < {}; final {} <= x < {}, {} <= y < {}; " "scissor {} <= x < {}, {} <= y < {}; surface_pitch={}", 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; diff --git a/thirdparty/rexglue-sdk/src/native/ui/CMakeLists.txt b/thirdparty/rexglue-sdk/src/native/ui/CMakeLists.txt index 8192ab3e..804bc5a8 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/CMakeLists.txt +++ b/thirdparty/rexglue-sdk/src/native/ui/CMakeLists.txt @@ -17,6 +17,7 @@ set(REXUI_CORE_SOURCES set(REXUI_OVERLAY_SOURCES ${REXGLUE_ROOT}/src/ui/overlay/debug_overlay.cpp + ${REXGLUE_ROOT}/src/ui/overlay/build_stamp_overlay.cpp ${REXGLUE_ROOT}/src/ui/overlay/console_overlay.cpp ${REXGLUE_ROOT}/src/ui/overlay/settings_overlay.cpp ) diff --git a/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp b/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp index 35b3ac2c..29c6630e 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp @@ -10,6 +10,7 @@ */ #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -585,8 +587,12 @@ void D3D12Presenter::PaintContext::DestroySwapChain() { Presenter::PaintResult D3D12Presenter::PaintAndPresentImpl(bool execute_ui_drawers) { if (paint_context_.HasFrameLatencyWaitableObject()) { + auto wait_begin = std::chrono::steady_clock::now(); DWORD wait_result = WaitForSingleObjectEx(paint_context_.swap_chain_latency_waitable_object, 1000, FALSE); + ui::SetLastPresentWaitMs( + std::chrono::duration(std::chrono::steady_clock::now() - wait_begin) + .count()); if (wait_result == WAIT_FAILED) { REXLOG_WARN("D3D12Presenter: Waiting for the swap chain frame latency object failed"); } else if (wait_result == WAIT_TIMEOUT) { diff --git a/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp b/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp index a40c36d8..2a4e50d4 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,16 @@ REXCVAR_DEFINE_BOOL(host_present_from_non_ui_thread, true, "UI/Presenter", REXCVAR_DEFINE_BOOL(present_letterbox, true, "UI/Presenter", "Enable letterboxing for non-native aspect ratios"); +namespace rex::ui { +namespace { +std::atomic g_last_present_wait_ms{0.0}; +} // namespace +double GetLastPresentWaitMs() { return g_last_present_wait_ms.load(std::memory_order_relaxed); } +void SetLastPresentWaitMs(double ms) { + g_last_present_wait_ms.store(ms, std::memory_order_relaxed); +} +} // namespace rex::ui + REXCVAR_DEFINE_INT32(present_safe_area_x, 90, "UI/Presenter", "Horizontal safe area percentage (0-100)") .range(0, 100); diff --git a/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp b/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp index cab8a82c..cbfe4d5a 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/rex_app.cpp @@ -43,6 +43,13 @@ REXCVAR_DEFINE_STRING(user_data_root, "", "Runtime", "Override user data path"); REXCVAR_DEFINE_STRING(update_data_root, "", "Runtime", "Override update data path"); +REXCVAR_DEFINE_BOOL(use_shader_disk_cache, true, "GPU", + "Pre-compile the game's GPU pipelines from a persistent on-disk cache at " + "launch (during load) and record new ones, so pipelines do not compile " + "on-demand mid-gameplay - the first-encounter stutter (e.g. at mission " + "start). First run of a scene records the pipelines; later runs load them. " + "Cache lives in ./cache/shaders/."); + namespace rex { // --- ReXApp --- @@ -216,6 +223,7 @@ bool ReXApp::OnInitialize() { imgui_drawer_->SetPresenterAndImmediateDrawer(presenter, immediate_drawer_.get()); // Built-in overlays debug_overlay_ = std::make_unique(imgui_drawer_.get()); + build_stamp_overlay_ = std::make_unique(imgui_drawer_.get()); console_overlay_ = std::make_unique(imgui_drawer_.get(), log_sink_); settings_overlay_ = std::make_unique( imgui_drawer_.get(), config_path); @@ -246,6 +254,29 @@ bool ReXApp::OnInitialize() { return; } + // Wire up the GPU pipeline disk cache. The backend has the whole shader + // storage system (record + pre-create), but nothing triggers it otherwise, + // so every run compiles pipelines on demand during gameplay - the + // first-encounter stutter (mission start). Init here, now that the title is + // loaded (title_id known) and the GPU is set up: blocking, so previously + // recorded pipelines are pre-created during the initial load rather than + // mid-play, and new ones are recorded for next time. + if (REXCVAR_GET(use_shader_disk_cache) && runtime_->graphics_system() && + runtime_->kernel_state()) { + uint32_t shader_cache_title_id = runtime_->kernel_state()->title_id(); + if (shader_cache_title_id != 0) { + std::filesystem::path cache_root = std::filesystem::current_path() / "cache"; + REXLOG_INFO("Shader disk cache: initializing for title {:08X} at {}", + shader_cache_title_id, rex::path_to_utf8(cache_root)); + // graphics_system() returns the IGraphicsSystem interface; InitializeShaderStorage + // lives on the concrete GraphicsSystem (always a rex::graphics::GraphicsSystem here). + static_cast(runtime_->graphics_system()) + ->InitializeShaderStorage(cache_root, shader_cache_title_id, true); + } else { + REXLOG_WARN("Shader disk cache: title_id unavailable, skipping"); + } + } + module_thread_ = std::thread([this, main_thread = std::move(main_thread)]() mutable { main_thread->Wait(0, 0, 0, nullptr); REXLOG_INFO("Execution complete"); @@ -279,6 +310,7 @@ void ReXApp::OnDestroy() { // ImGui cleanup (reverse of setup) settings_overlay_.reset(); console_overlay_.reset(); + build_stamp_overlay_.reset(); debug_overlay_.reset(); if (imgui_drawer_) { imgui_drawer_->SetPresenterAndImmediateDrawer(nullptr, nullptr); diff --git a/thirdparty/rexglue-sdk/src/ui/CMakeLists.txt b/thirdparty/rexglue-sdk/src/ui/CMakeLists.txt index 0ed53f27..105a26f0 100644 --- a/thirdparty/rexglue-sdk/src/ui/CMakeLists.txt +++ b/thirdparty/rexglue-sdk/src/ui/CMakeLists.txt @@ -19,6 +19,7 @@ set(REXUI_CORE_SOURCES # Optional overlay dialogs set(REXUI_OVERLAY_SOURCES overlay/debug_overlay.cpp + overlay/build_stamp_overlay.cpp overlay/console_overlay.cpp overlay/settings_overlay.cpp ) diff --git a/thirdparty/rexglue-sdk/src/ui/overlay/build_stamp_overlay.cpp b/thirdparty/rexglue-sdk/src/ui/overlay/build_stamp_overlay.cpp new file mode 100644 index 00000000..c607ed32 --- /dev/null +++ b/thirdparty/rexglue-sdk/src/ui/overlay/build_stamp_overlay.cpp @@ -0,0 +1,40 @@ +/** + * @file ui/overlay/build_stamp_overlay.cpp + * @brief Always-on build-stamp watermark. See build_stamp_overlay.h. + * + * @copyright Copyright (c) 2026 Project Gracemeria + * @license BSD 3-Clause License + */ +#include +#include +#include +#include + +REXCVAR_DEFINE_BOOL(show_build_stamp, true, "UI", + "Draw the build-stamp watermark (short git commit hash) in the " + "lower-left corner. On by default; set false to hide it."); + +namespace rex::ui { + +BuildStampOverlay::BuildStampOverlay(ImGuiDrawer* imgui_drawer) : ImGuiDialog(imgui_drawer) {} + +void BuildStampOverlay::OnDraw(ImGuiIO& io) { + if (!REXCVAR_GET(show_build_stamp)) + return; + + const float margin = io.DisplaySize.y * 0.02f; + const ImVec2 size = ImGui::CalcTextSize(REXGLUE_BUILD_HASH_STAMP); + ImGui::SetNextWindowPos(ImVec2(margin, io.DisplaySize.y - size.y - margin)); + ImGui::SetNextWindowSize(ImVec2(0, 0)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 0.5f)); + if (ImGui::Begin("##buildstamp", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(REXGLUE_BUILD_HASH_STAMP); + } + ImGui::End(); + ImGui::PopStyleColor(); +} + +} // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp b/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp index c581507b..ad24369d 100644 --- a/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp +++ b/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp @@ -11,9 +11,13 @@ */ #include #include +#include #include #include +#include +#include + namespace rex::ui { DebugOverlayDialog::DebugOverlayDialog(ImGuiDrawer* imgui_drawer, FrameStatsProvider stats_provider) @@ -29,36 +33,110 @@ void DebugOverlayDialog::OnDraw(ImGuiIO& io) { if (!visible_) return; - ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(220, 60), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.5f); - if (ImGui::Begin("Debug##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) { - ImGui::Text("Host: %.1f FPS (%.2f ms)", io.Framerate, 1000.0f / io.Framerate); - if (stats_provider_) { - auto stats = stats_provider_(); - if (stats.frame_count > 0) { - ImGui::Text("Guest: %.1f FPS (%.2f ms)", stats.fps, stats.frame_time_ms); - } - } - } - ImGui::End(); + // --- Sample this host frame -------------------------------------------- + const double host_ms = io.DeltaTime * 1000.0; - // Build stamp watermark -- centered near bottom of screen - auto text_size = ImGui::CalcTextSize(REXGLUE_BUILD_STAMP); - float padding = ImGui::GetStyle().WindowPadding.x * 2.0f; - float bottom_offset = io.DisplaySize.y * 0.03f; - ImGui::SetNextWindowPos(ImVec2((io.DisplaySize.x - text_size.x - padding) * 0.5f, - io.DisplaySize.y - text_size.y - bottom_offset)); - ImGui::SetNextWindowSize(ImVec2(0, 0)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 0.5f)); - if (ImGui::Begin("##watermark", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextUnformatted(REXGLUE_BUILD_STAMP); + FrameStats stats; + bool have_guest = false; + if (stats_provider_) { + stats = stats_provider_(); + have_guest = stats.frame_count > 0; + } + + // Counter spread metric: track guest frametime extremes per ACTUAL guest frame. + if (have_guest && stats.frame_count != last_guest_frame_count_) { + last_guest_frame_count_ = stats.frame_count; + window_guest_ms_min_ = (std::min)(window_guest_ms_min_, stats.frame_time_ms); + window_guest_ms_max_ = (std::max)(window_guest_ms_max_, stats.frame_time_ms); + } + + // Push graph samples at a FIXED TIME interval (kGraphSampleSeconds), not per + // frame, so both graphs scroll at a consistent rate regardless of fps. Each + // sample carries the max frametime since the last sample (spikes preserved). + graph_host_ms_max_ = (std::max)(graph_host_ms_max_, float(host_ms)); + if (have_guest) { + graph_guest_ms_max_ = (std::max)(graph_guest_ms_max_, float(stats.frame_time_ms)); + } + graph_sample_accum_s_ += io.DeltaTime; + if (graph_sample_accum_s_ >= kGraphSampleSeconds) { + bool first = true; + while (graph_sample_accum_s_ >= kGraphSampleSeconds) { + graph_sample_accum_s_ -= kGraphSampleSeconds; + // First push uses the accumulated max; extra pushes (a frame longer than + // several sample intervals - a hitch) repeat the current frametime so the + // graph fills the gap at the right time width instead of dropping to zero. + host_ms_history_[host_history_pos_] = first ? graph_host_ms_max_ : float(host_ms); + host_history_pos_ = (host_history_pos_ + 1) % kHistory; + guest_ms_history_[guest_history_pos_] = + first ? graph_guest_ms_max_ : (have_guest ? float(stats.frame_time_ms) : 0.0f); + guest_history_pos_ = (guest_history_pos_ + 1) % kHistory; + first = false; + } + graph_host_ms_max_ = 0.0f; + graph_guest_ms_max_ = 0.0f; + } + + // --- Throttled counters (averaged over kCounterRefreshSeconds) ---------- + counter_window_s_ += io.DeltaTime; + ++host_frames_in_window_; + if (counter_window_s_ >= kCounterRefreshSeconds) { + disp_host_fps_ = host_frames_in_window_ / counter_window_s_; + disp_host_ms_ = disp_host_fps_ > 0.0 ? 1000.0 / disp_host_fps_ : 0.0; + if (have_guest) { + const double guest_frames = double(stats.frame_count - guest_frames_at_window_start_); + disp_guest_fps_ = guest_frames / counter_window_s_; + disp_guest_ms_ = disp_guest_fps_ > 0.0 ? 1000.0 / disp_guest_fps_ : 0.0; + disp_guest_ms_min_ = window_guest_ms_min_ < 1.0e8 ? window_guest_ms_min_ : 0.0; + disp_guest_ms_max_ = window_guest_ms_max_; + } + counter_window_s_ = 0.0; + host_frames_in_window_ = 0; + guest_frames_at_window_start_ = have_guest ? stats.frame_count : 0; + window_guest_ms_min_ = 1.0e9; + window_guest_ms_max_ = 0.0; + } + + // --- Draw --------------------------------------------------------------- + ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(320, 0), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.55f); + if (ImGui::Begin("Frame timing##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Host: %6.1f FPS %5.2f ms", disp_host_fps_, disp_host_ms_); + // Host = the presenter's paint/present rate = what an external tool like RTSS + // measures. It is DECOUPLED from the guest game rate below (the presenter + // paints on the UI thread at ~monitor refresh), so Host >> Guest is expected + // and is why RTSS reads the present rate, not the game fps. Present-wait = how + // long paint blocked on the GPU/display (high = GPU-bound; ~0 = pacing-bound). + ImGui::SameLine(); + ImGui::TextDisabled(" wait %.2f ms", GetLastPresentWaitMs()); + if (have_guest) { + ImGui::Text("Guest: %6.1f FPS %5.2f ms", disp_guest_fps_, disp_guest_ms_); + // Spread over the window is the numeric shake metric: a wide min/max at a + // steady average fps is exactly the uneven-pacing / aircraft-shake signal. + ImGui::TextDisabled("guest ft min %.2f max %.2f spread %.2f ms", disp_guest_ms_min_, + disp_guest_ms_max_, disp_guest_ms_max_ - disp_guest_ms_min_); + } + + ImGui::Separator(); + + // Both graphs share a fixed 0-40ms scale so host vs guest jaggedness is + // directly comparable. Flat host + jagged guest == sim-dt jitter (the shake + // is in the game's timing, not the presenter); jagged both == real delivery + // unevenness (look at the pacing/limiter). + char overlay[48]; + std::snprintf(overlay, sizeof(overlay), "host %.2f ms (now)", host_ms); + ImGui::PlotLines("##host_ft", host_ms_history_, kHistory, host_history_pos_, overlay, 0.0f, + 40.0f, ImVec2(0, 55)); + if (have_guest) { + std::snprintf(overlay, sizeof(overlay), "guest %.2f ms (now)", stats.frame_time_ms); + ImGui::PlotLines("##guest_ft", guest_ms_history_, kHistory, guest_history_pos_, overlay, + 0.0f, 40.0f, ImVec2(0, 55)); + } + ImGui::TextDisabled("graphs 0-40 ms, ~%.0fs window (max/%.0fms) | counters avg %.1fs", + kHistory * kGraphSampleSeconds, kGraphSampleSeconds * 1000.0, + kCounterRefreshSeconds); } ImGui::End(); - ImGui::PopStyleColor(); } } // namespace rex::ui