diff --git a/CMakeLists.txt b/CMakeLists.txt index 84f757c6..3ccb52f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ set(AC6RECOMP_SOURCES src/ac6_backend_fixes/ac6_backend_capture_bridge.cpp src/ac6_backend_fixes/ac6_backend_hooks.cpp src/ac6_backend_fixes/ac6_backend_pass_classifier.cpp + src/ac6_backend_fixes/ac6_fps_physics_fix.cpp ) if(WIN32) diff --git a/ac6recomp_config.toml b/ac6recomp_config.toml index f1757327..bf6f83ba 100644 --- a/ac6recomp_config.toml +++ b/ac6recomp_config.toml @@ -10585,6 +10585,27 @@ address = 0x821EFD38 name = "ac6DeltaDivisorHook" registers = ["r29"] +# Fractional-remainder delta precision. +# +# The game's frame delta is an INTEGER: at 0x821EFD5C it computes +# r11 = (elapsed_ticks * 100) / (freq / divisor), then r8 = min(r11 + 1, 100). +# The truncation plus the +1 guard bias the delta HIGH by (1 - frac) every +# frame: +2% at 60fps (51 vs 50.85 true), +10% at 300fps (11 vs ~10.2) — a +# systematic game-speed inflation at unlocked framerates, and the error only +# grows with fps because the integer gets smaller. +# +# This hook fires at 0x821EFD74, right after the guard/cap, where r8 = final +# integer delta, r30 = elapsed timebase ticks, r10 = ticks-per-frame +# (freq / divisor), r29 = divisor. While the unlock's divisor remap is active +# (r29 == 30) it replaces r8 with floor(exact + remainder), carrying the +# fractional remainder across frames so the SUM of deltas tracks real time +# exactly (average bias -> 0 at any framerate). Inactive (30fps native, +# cutscene clamp, menus) it leaves the vanilla value untouched. +[[midasm_hook]] +address = 0x821EFD74 +name = "ac6DeltaPrecisionHook" +registers = ["r8", "r10", "r29", "r30"] + # Record frame timestamps at each D3DDevice_Swap call for timing overlay. # Placed before the device[21516] branch so it fires unconditionally — # when that field is non-zero the function skips VdSwap, which caused diff --git a/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp new file mode 100644 index 00000000..ce3e8877 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp @@ -0,0 +1,171 @@ +// Framerate-independent flight dynamics for the FPS unlock. +// +// The game's flight model mixes two timing styles: +// - Kinematics are delta-scaled and framerate-correct: position integration, +// the persistent-velocity wind relaxation, and the control-surface ramps +// all multiply by the adaptive frame delta (see ac6DeltaDivisorHook). +// - The longitudinal force/speed-command dynamics accumulate FIXED PER-FRAME +// steps sized for the native 30fps cadence (constants assume a 33.3ms +// frame, no delta anywhere in the chain). +// +// With ac6_unlock_fps the dynamics tick 2x+ as often, so throttle accel/decel, +// gravity response, and high-G speed bleed all scale with framerate. Cruise +// speed stays correct at any rate because it is the accumulator's equilibrium +// (per-step gain and decay cancel), which is call-rate independent. +// +// The two per-frame-stepped accumulators, located by static analysis of the +// flight-model class (ctor rex_sub_82328F48/82329160, embedded in the aircraft +// at this+10672, base class 0x82303xxx): +// +// - rex_sub_823046A0 (core force step, generated recomp.22): [this+1320] is +// the persistent longitudinal force/speed command. Thrust ([this+972] and +// the rex_sub_82282BC8 term), overspeed drag (fractions of the reference +// speeds [this+1264]/[this+1268]), min-speed correction ([this+1280]) and +// the high-G/AoA bleed all add constant-sized steps to it each call. +// Everything downstream (velocity blend, position += velocity * dt) is +// delta-scaled. +// - rex_sub_82329B40 (input shaping, generated recomp.23): [this+1456] is +// the accel/brake trigger command in [-1, 1], ramped toward +/-1 and +// decayed toward 0 by per-frame constants. (The stick lags at +360/+364, +// hold timers +368/+372 and the +304/+312/+1364 terms in the same class +// are already delta-scaled and are left untouched.) +// +// Fix: wrap both guest functions and rescale each accumulator's NET per-call +// change by frame_ms / 33.333 (clamped to <= 1): +// +// x_new = x_pre + (x_post - x_pre) * ratio +// +// Scaling the per-step delta of a feedback accumulator by the step-frequency +// ratio reproduces the same continuous-time dynamics the game has at 30fps, +// without touching the delta-scaled math inside. At native cadence the ratio +// is 1 and the wrapper is a bit-exact pass-through. The wrapper applies to +// every aircraft that runs this flight model (player and AI), keeping the +// whole sim consistent. +// +// Both symbols are weak in the generated code and registered by address in +// ac6recomp_init, so these strong overrides capture direct calls and vtable +// dispatch alike. + +#include +#include +#include +#include + +#include +#include +#include +#include + +#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 +// (stepped per frame by rex_sub_823046A0 without delta scaling). +constexpr uint32_t kForceCommandOffset = 1320; +// Accel/brake trigger command in [-1, 1] (ramped/decayed per frame by +// rex_sub_82329B40 without delta scaling). +constexpr uint32_t kTriggerCommandOffset = 1456; + +// The game's native simulation cadence the per-frame constants were tuned for. +constexpr double kNativeFrameMs = 1000.0 / 30.0; + +// Everything below runs only on the guest sim thread (the flight-model +// 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. +double StepRatio() { + static uint64_t s_cached_frame = ~uint64_t(0); + static double s_cached_ratio = 1.0; + const ac6::FrameStats stats = ac6::GetFrameStats(); + if (stats.frame_count == s_cached_frame) { + return s_cached_ratio; + } + s_cached_frame = stats.frame_count; + s_cached_ratio = 1.0; + if (!REXCVAR_GET(ac6_fps_physics_fix) || !ac6::TimingHooksActive()) { + return s_cached_ratio; + } + if (stats.frame_time_ms <= 0.0) { + return s_cached_ratio; // no frame measured yet + } + double ratio = stats.frame_time_ms / kNativeFrameMs; + if (ratio > 1.0) { + ratio = 1.0; // never blend past the native 30fps step + } else if (ratio < 0.02) { + ratio = 0.02; // sanity floor against a bogus frame-time sample + } + s_cached_ratio = ratio; + return s_cached_ratio; +} + +// Rescales the net change the wrapped call made to one guest float: +// field = pre + (post - pre) * ratio. Logs the first activation per field so +// each wrapper gets its own confirmation line in the log. +void BlendFieldDelta(uint8_t* base, uint32_t ea, float pre, double ratio, const char* what, + bool& logged) { + const float post = rex::memory::load_and_swap(base + ea); + if (post == pre) { + return; + } + rex::memory::store_and_swap(base + ea, static_cast(pre + (post - pre) * ratio)); + if (!logged) { + logged = true; + REXLOG_INFO("[AC6-PHYS-FIX] active: {} delta {:+.5f} scaled by {:.3f}", what, post - pre, + ratio); + } +} + +} // namespace + +PPC_EXTERN_FUNC(__imp__rex_sub_823046A0); // flight-model force step +PPC_EXTERN_FUNC(__imp__rex_sub_82329B40); // flight-model input shaping + +// Flight-model core force step (0x823046A0): rescale the per-frame stepped +// longitudinal force/speed command at [this+1320]. +PPC_FUNC_IMPL(rex_sub_823046A0) { + PPC_FUNC_PROLOGUE(); + + const uint32_t self = ctx.r3.u32; + const double ratio = StepRatio(); + // 1.0 is the exact pass-through value (native cadence, or the fix disabled). + if (ratio == 1.0 || self == 0) { + __imp__rex_sub_823046A0(ctx, base); + return; + } + + const float pre = rex::memory::load_and_swap(base + self + kForceCommandOffset); + __imp__rex_sub_823046A0(ctx, base); + static bool s_logged = false; + BlendFieldDelta(base, self + kForceCommandOffset, pre, ratio, "force-command(+1320)", s_logged); +} + +// Flight-model input shaping (0x82329B40): rescale the per-frame ramped +// accel/brake trigger command at [this+1456]. +PPC_FUNC_IMPL(rex_sub_82329B40) { + PPC_FUNC_PROLOGUE(); + + const uint32_t self = ctx.r3.u32; + const double ratio = StepRatio(); + if (ratio == 1.0 || self == 0) { + __imp__rex_sub_82329B40(ctx, base); + return; + } + + const float pre = rex::memory::load_and_swap(base + self + kTriggerCommandOffset); + __imp__rex_sub_82329B40(ctx, base); + static bool s_logged = false; + BlendFieldDelta(base, self + kTriggerCommandOffset, pre, ratio, "trigger-command(+1456)", + s_logged); +} diff --git a/src/ac6_texture_overrides.cpp b/src/ac6_texture_overrides.cpp index 0bdb2270..cf9d2785 100644 --- a/src/ac6_texture_overrides.cpp +++ b/src/ac6_texture_overrides.cpp @@ -19,11 +19,13 @@ REXCVAR_DECLARE(std::string, user_data_root); -REXCVAR_DEFINE_BOOL(ac6_texture_swaps_enabled, true, "AC6/TextureSwaps", - "Enable AC6 texture dump and replacement support"); +REXCVAR_DEFINE_BOOL(ac6_texture_swaps_enabled, false, "AC6/TextureSwaps", + "Enable AC6 texture dump and replacement support (default off: the " + "replacement lookup currently checks the filesystem on every texture " + "upload, which can cause frame stutters; enable for texture modding)"); REXCVAR_DEFINE_BOOL(ac6_texture_swaps_dump_enabled, false, "AC6/TextureSwaps", "Dump host-ready textures to the user-data texture dump folder"); -REXCVAR_DEFINE_BOOL(ac6_texture_swaps_replace_enabled, true, "AC6/TextureSwaps", +REXCVAR_DEFINE_BOOL(ac6_texture_swaps_replace_enabled, false, "AC6/TextureSwaps", "Load matching replacement DDS files from the user-data texture override folders"); REXCVAR_DEFINE_STRING(ac6_texture_swaps_dump_dir, "texture_dumps", "AC6/TextureSwaps", "User-data subdirectory that stores dumped texture DDS files and metadata"); diff --git a/src/main.cpp b/src/main.cpp index f0c63723..a90c54d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,8 @@ REXCVAR_DECLARE(bool, guest_vblank_sync_to_refresh); REXCVAR_DECLARE(bool, host_present_from_non_ui_thread); #if REX_HAS_D3D12 REXCVAR_DECLARE(bool, d3d12_allow_variable_refresh_rate_and_tearing); +REXCVAR_DECLARE(bool, d3d12_low_latency_swap_chain); +REXCVAR_DECLARE(int32_t, d3d12_max_frame_latency); #endif REXCVAR_DECLARE(bool, vfetch_index_rounding_bias); REXCVAR_DECLARE(int32_t, video_mode_width); @@ -114,11 +116,17 @@ void ApplyAc6DefaultSettings() { REXCVAR_SET(guest_vblank_sync_to_refresh, true); } if (!rex::cvar::HasNonDefaultValue("host_present_from_non_ui_thread")) { - REXCVAR_SET(host_present_from_non_ui_thread, false); + REXCVAR_SET(host_present_from_non_ui_thread, true); } #if REX_HAS_D3D12 if (!rex::cvar::HasNonDefaultValue("d3d12_allow_variable_refresh_rate_and_tearing")) { - REXCVAR_SET(d3d12_allow_variable_refresh_rate_and_tearing, false); + REXCVAR_SET(d3d12_allow_variable_refresh_rate_and_tearing, true); + } + if (!rex::cvar::HasNonDefaultValue("d3d12_low_latency_swap_chain")) { + REXCVAR_SET(d3d12_low_latency_swap_chain, true); + } + if (!rex::cvar::HasNonDefaultValue("d3d12_max_frame_latency")) { + REXCVAR_SET(d3d12_max_frame_latency, 1); } #endif if (!rex::cvar::HasNonDefaultValue("vfetch_index_rounding_bias")) { diff --git a/src/render_hooks.cpp b/src/render_hooks.cpp index a5eca64d..cc3a5728 100644 --- a/src/render_hooks.cpp +++ b/src/render_hooks.cpp @@ -4,8 +4,11 @@ #include #include +#include #include +#include +#include #include REXCVAR_DEFINE_BOOL(ac6_unlock_fps, false, "AC6", "Unlock frame rate to 60fps"); @@ -14,6 +17,17 @@ REXCVAR_DEFINE_BOOL(ac6_timing_hooks_enabled, true, "AC6", 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"); +REXCVAR_DEFINE_BOOL(ac6_dynamic_vblank, true, "AC6", + "With the FPS unlock active, pace frame-locked content (menus, " + "cutscenes, pause) at the native 60Hz guest vblank while gameplay " + "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)"); using Clock = std::chrono::steady_clock; @@ -34,21 +48,56 @@ std::atomic g_last_cinematic_tick_ms{INT64_MIN}; // auto-releases shortly after it ends. constexpr int64_t kCinematicDecayMs = 100; +// Wall-clock ms of the last draw using the world/effects compositor pixel +// shader (stamped by the GPU command processor via NotifyWorldCompositorDraw). +// The compositor runs every frame the 3D world renders and never in the 2D +// front-end, so its freshness distinguishes free-runnable gameplay from +// frame-locked menus/hangar. (The delta-time hook was tried first as this +// signal, but it lives in the frame layer and ticks in menus too.) +// INT64_MIN = never drawn. +std::atomic g_last_world_draw_ms{INT64_MIN}; +constexpr int64_t kWorldDrawDecayMs = 300; + int64_t NowMs() { return std::chrono::duration_cast( Clock::now().time_since_epoch()) .count(); } +bool IsWorldRenderActive() { + const int64_t last = g_last_world_draw_ms.load(std::memory_order_relaxed); + if (last == INT64_MIN) { + return false; + } + return (NowMs() - last) <= kWorldDrawDecayMs; +} + bool AreTimingHooksActive() { + // The world-render gate only applies under dynamic vblank pacing: it exists + // 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) && - !ac6::IsCinematicActive(); + !ac6::IsCinematicActive() && + (!REXCVAR_GET(ac6_dynamic_vblank) || IsWorldRenderActive()); } } // namespace namespace ac6 { +// True while the FPS-unlock timing hooks are remapping the game's cadence +// (unlock cvars on and no cutscene clamp). The physics force-step rescale keys +// off this so it stays in lockstep with the frame-delta hooks: whenever the +// delta reverts to vanilla, the force step must too. +bool TimingHooksActive() { + return AreTimingHooksActive(); +} + +bool WorldRenderActiveRecently() { + return IsWorldRenderActive(); +} + bool IsCinematicActive() { if (!REXCVAR_GET(ac6_cutscene_clamp)) { return false; @@ -81,9 +130,70 @@ void ac6DeltaDivisorHook(PPCRegister& r29) { r29.u64 = 30; } +// Fires right after the game turns the measured frame time into its integer +// delta: r8 = min(floor(elapsed*100 / (freq/divisor)) + 1, 100). Under the +// unlock the floor plus the +1 guard bias the delta high by (1 - frac) every +// frame - a systematic speed-up at unlocked framerates (+2% @60fps, +10% @300). +// Carry the fractional remainder across frames so the SUM of integer deltas +// tracks real time exactly. Only active while the divisor remap is (r29 == 30); +// otherwise the vanilla value passes through and the remainder resets so no +// stale correction leaks across mode changes. +void ac6DeltaPrecisionHook(PPCRegister& r8, PPCRegister& r10, PPCRegister& r29, PPCRegister& r30) { + static double s_remainder = 0.0; + + // Cheapest gates first: the register compares are free, the cvar/clock work + // only runs on the frames that can actually take the rewrite path. + if (r29.u32 != 30 || r10.u32 == 0 || !AreTimingHooksActive()) { + s_remainder = 0.0; + return; + } + if (!REXCVAR_GET(ac6_delta_precision)) { + s_remainder = 0.0; + return; + } + const double max_delta = 100.0; // the game's stock 30fps delta clamp + + // 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. + 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; + 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 + } else if (delta > max_delta) { + delta = max_delta; + } + s_remainder = base - delta; + r8.u64 = uint64_t(delta); +} + void ac6PresentTimingHook(PPCRegister& /*r31*/) { // ac6::d3d::OnFrameBoundary(); // MOVED TO GPU THREAD + // Dynamic vblank pacing: free-run only while the 3D world is rendering; pace + // 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 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. + double override_hz = 0.0; + if (dynamic_pacing && !free_running) { + override_hz = 60.0; + } + rex::graphics::GraphicsSystem::SetGuestVblankHzOverride(override_hz); + const auto now = Clock::now(); if (g_frame_start.time_since_epoch().count() != 0) { const double frame_time_ms = @@ -94,6 +204,19 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) { g_frame_count.fetch_add(1, std::memory_order_relaxed); } g_frame_start = now; + + // Log the first handful of pacing transitions. + static double last_log_key = -0.5; + static uint32_t transition_logs = 0; + if (unlock && override_hz != last_log_key && transition_logs < 32) { + ++transition_logs; + 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; } void ac6CinematicTickHook(PPCRegister& /*r3*/) { @@ -113,4 +236,8 @@ FrameStats GetFrameStats() { g_frame_count.load(std::memory_order_relaxed)}; } +void NotifyWorldCompositorDraw() { + g_last_world_draw_ms.store(NowMs(), std::memory_order_relaxed); +} + } // namespace ac6 diff --git a/src/render_hooks.h b/src/render_hooks.h index 2fd7c77b..4b57a83b 100644 --- a/src/render_hooks.h +++ b/src/render_hooks.h @@ -19,16 +19,34 @@ struct FrameStats { FrameStats GetFrameStats(); +// True while the FPS-unlock timing hooks are remapping the game's cadence +// (unlock cvars on and no cutscene clamp). The physics force-step rescale keys +// off this so it stays in lockstep with the frame-delta hooks. +bool TimingHooksActive(); + // 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. bool IsCinematicActive(); +// Called by the GPU command processor whenever a draw uses the world/effects +// compositor pixel shader (guest ucode 17e5e4ac3e713245). The compositor runs +// once per frame whenever the 3D world is being rendered and never in the 2D +// front-end (menus, hangar), so its freshness is the "gameplay world active" +// signal for the dynamic FPS pacing. +void NotifyWorldCompositorDraw(); + +// True while a world-compositor draw happened within the last decay window +// (i.e. the 3D world is being rendered - gameplay or in-engine cutscene, not +// the 2D front-end). Same signal the dynamic FPS pacing uses. +bool WorldRenderActiveRecently(); + } // namespace ac6 bool ac6FlipIntervalHook(); bool ac6PresentIntervalHook(PPCRegister& r10); void ac6DeltaDivisorHook(PPCRegister& r29); +void ac6DeltaPrecisionHook(PPCRegister& r8, PPCRegister& r10, PPCRegister& r29, PPCRegister& r30); void ac6PresentTimingHook(PPCRegister& r31); // Fires once per frame from the demo-manager Exec while a cutscene is playing. diff --git a/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h b/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h index 6ede3215..e03c9a18 100644 --- a/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h +++ b/thirdparty/rexglue-sdk/include/rex/graphics/graphics_system.h @@ -102,6 +102,13 @@ class GraphicsSystem : public system::IGraphicsSystem { return last_vblank_interrupt_guest_tick_.load(std::memory_order_acquire); } + // Per-game override of the guest vblank rate, in Hz. 0 = no override (use + // the vsync/guest_vblank_sync_to_refresh cvar logic). Lets game-specific + // timing hooks pace frame-locked content (menus, cinematics) at the native + // rate while gameplay free-runs. Process-wide, not per-instance. + static void SetGuestVblankHzOverride(double hz); + static double GetGuestVblankHzOverride(); + 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 59108668..7afd000e 100644 --- a/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/d3d12/command_processor.cpp @@ -35,6 +35,7 @@ #include "../../../../../src/ac6_backend_fixes/ac6_backend_hooks.h" #include "../../../../../src/ac6_native_graphics.h" +#include "../../../../../src/render_hooks.h" REXCVAR_DEFINE_BOOL(d3d12_bindless, true, "GPU/D3D12", "Use bindless resources where available") .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); @@ -2660,6 +2661,13 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3 bool memexport_used_pixel = pixel_shader && (pixel_shader->memexport_eM_written() != 0); bool memexport_used = memexport_used_vertex || memexport_used_pixel; + // AC6: the world/effects compositor draws once per frame whenever the 3D + // world renders (never in the 2D front-end) - stamp it as the "gameplay + // world active" signal for the dynamic FPS pacing. + if (pixel_shader && pixel_shader->ucode_data_hash() == UINT64_C(0x17e5e4ac3e713245)) { + ac6::NotifyWorldCompositorDraw(); + } + if (!BeginSubmission(true)) { return false; } diff --git a/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp b/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp index 6d04b74c..56506d7d 100644 --- a/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp +++ b/thirdparty/rexglue-sdk/src/graphics/graphics_system.cpp @@ -200,7 +200,8 @@ X_STATUS GraphicsSystem::SetupGuestGpu(runtime::FunctionDispatcher* function_dis vsync_worker_running_ = true; vsync_worker_thread_ = system::object_ref( new system::XHostThread(kernel_state_, 128 * 1024, 0, [this, vsync_interval_ticks, - no_vsync_interval_ticks]() { + no_vsync_interval_ticks, + guest_tick_frequency]() { uint64_t last_frame_time = chrono::Clock::QueryGuestTickCount(); while (vsync_worker_running_) { uint64_t current_time = chrono::Clock::QueryGuestTickCount(); @@ -208,6 +209,17 @@ X_STATUS GraphicsSystem::SetupGuestGpu(runtime::FunctionDispatcher* function_dis ? vsync_interval_ticks : (REXCVAR_GET(vsync) ? vsync_interval_ticks : no_vsync_interval_ticks); + double vblank_hz_override = GetGuestVblankHzOverride(); + if (vblank_hz_override > 0.0) { + interval_ticks = std::max( + uint64_t(1), uint64_t(double(guest_tick_frequency) / vblank_hz_override)); + } + // Re-anchor when far behind so a shrinking interval (an override + // switching from a paced rate to a much faster one) or a long stall + // does not burst a backlog of MarkVblank calls in one wake. + if (current_time - last_frame_time >= interval_ticks * 4) { + last_frame_time = current_time - interval_ticks; + } while (current_time - last_frame_time >= interval_ticks) { MarkVblank(); last_frame_time += interval_ticks; @@ -399,6 +411,18 @@ void GraphicsSystem::DispatchInterruptCallback(uint32_t source, uint32_t cpu) { rex::countof(args)); } +namespace { +std::atomic g_guest_vblank_hz_override{0.0}; +} // namespace + +void GraphicsSystem::SetGuestVblankHzOverride(double hz) { + g_guest_vblank_hz_override.store(hz, std::memory_order_relaxed); +} + +double GraphicsSystem::GetGuestVblankHzOverride() { + return g_guest_vblank_hz_override.load(std::memory_order_relaxed); +} + void GraphicsSystem::MarkVblank() { // TODO: Enable profiling once ported // SCOPE_profile_cpu_f("gpu");