From 175480efb75272cfdd0c0ded0d082795860d5d80 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:33:39 +0300 Subject: [PATCH 1/4] Fix aircraft acceleration/deceleration at high FPS The flight model's longitudinal force/speed-command accumulator and the accel/brake trigger command are stepped by fixed per-frame constants with no delta, so with ac6_unlock_fps active the plane accelerates and brakes proportionally faster at higher frame rates. Wrap the two flight-model functions (rex_sub_823046A0 force step, rex_sub_82329B40 input shaping) and rescale each accumulator's net per-call change by frame_time / 33.3ms, reproducing the native 30fps continuous-time dynamics at any frame rate. Bit-exact pass-through at <=30fps; applies to player and AI aircraft. Gated by ac6_fps_physics_fix (default on) and only active while the unlock's timing hooks are (ac6::TimingHooksActive). --- CMakeLists.txt | 1 + src/ac6_backend_fixes/ac6_fps_physics_fix.cpp | 171 ++++++++++++++++++ src/render_hooks.cpp | 8 + src/render_hooks.h | 5 + 4 files changed, 185 insertions(+) create mode 100644 src/ac6_backend_fixes/ac6_fps_physics_fix.cpp 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/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/render_hooks.cpp b/src/render_hooks.cpp index a5eca64d..b508e300 100644 --- a/src/render_hooks.cpp +++ b/src/render_hooks.cpp @@ -49,6 +49,14 @@ bool AreTimingHooksActive() { 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 IsCinematicActive() { if (!REXCVAR_GET(ac6_cutscene_clamp)) { return false; diff --git a/src/render_hooks.h b/src/render_hooks.h index 2fd7c77b..d1d293c5 100644 --- a/src/render_hooks.h +++ b/src/render_hooks.h @@ -19,6 +19,11 @@ 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. From 2b52e5e2c8726edbf5971d129005b25e9ff60458 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:38:29 +0300 Subject: [PATCH 2/4] Add dynamic vblank pacing for the FPS unlock (ac6_dynamic_vblank) With the unlock forcing single-vblank presents, frame-locked content (menus, hangar, in-engine cutscenes) free-runs far too fast while only gameplay should. Pace the guest vblank dynamically: free-run only while the 3D world is being rendered, otherwise force the native 60Hz. "World is rendering" is detected from the GPU side - the command processor stamps a heartbeat (NotifyWorldCompositorDraw) whenever a draw uses the world/effects compositor pixel shader (ucode 17e5e4ac3e713245), which runs every frame of 3D but never in the 2D front-end. The present timing hook then sets a guest-vblank Hz override via the new GraphicsSystem hook. Only engages under the unlock; ac6_dynamic_vblank=false restores the plain always-on unlock. --- src/render_hooks.cpp | 72 ++++++++++++++++++- src/render_hooks.h | 12 ++++ .../include/rex/graphics/graphics_system.h | 7 ++ .../src/graphics/d3d12/command_processor.cpp | 8 +++ .../src/graphics/graphics_system.cpp | 26 ++++++- 5 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/render_hooks.cpp b/src/render_hooks.cpp index b508e300..c3eb2b2e 100644 --- a/src/render_hooks.cpp +++ b/src/render_hooks.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include REXCVAR_DEFINE_BOOL(ac6_unlock_fps, false, "AC6", "Unlock frame rate to 60fps"); @@ -14,6 +16,12 @@ 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."); using Clock = std::chrono::steady_clock; @@ -34,15 +42,38 @@ 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 @@ -57,6 +88,10 @@ bool TimingHooksActive() { return AreTimingHooksActive(); } +bool WorldRenderActiveRecently() { + return IsWorldRenderActive(); +} + bool IsCinematicActive() { if (!REXCVAR_GET(ac6_cutscene_clamp)) { return false; @@ -92,6 +127,24 @@ void ac6DeltaDivisorHook(PPCRegister& r29) { 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 = @@ -102,6 +155,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*/) { @@ -121,4 +187,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 d1d293c5..401201cd 100644 --- a/src/render_hooks.h +++ b/src/render_hooks.h @@ -29,6 +29,18 @@ bool TimingHooksActive(); // 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(); 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"); From d9828e08c49398f787a85902990370795a2b5fa0 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:40:40 +0300 Subject: [PATCH 3/4] Fix integer frame-delta bias at high fps (ac6_delta_precision) The game builds its per-frame sim delta as floor(elapsed * 3000) + 1, capped at 100. The floor plus the +1 guard bias the delta high by (1 - frac) every frame, which is invisible at the native 30fps (the cap absorbs it) but becomes a systematic speed-up once the unlock frees the frame rate: about +2% at 60fps and +10% at 300fps. Add a hook on the delta computation that carries the fractional remainder across frames, so the summed integer deltas track real elapsed time exactly instead of rounding up every frame. Gated by ac6_delta_precision (default on) and only active while the unlock's divisor remap is. --- ac6recomp_config.toml | 21 +++++++++++++++++++ src/render_hooks.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++ src/render_hooks.h | 1 + 3 files changed, 71 insertions(+) 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/render_hooks.cpp b/src/render_hooks.cpp index c3eb2b2e..cc3a5728 100644 --- a/src/render_hooks.cpp +++ b/src/render_hooks.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -22,6 +23,11 @@ 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)"); using Clock = std::chrono::steady_clock; @@ -124,6 +130,49 @@ 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 diff --git a/src/render_hooks.h b/src/render_hooks.h index 401201cd..4b57a83b 100644 --- a/src/render_hooks.h +++ b/src/render_hooks.h @@ -46,6 +46,7 @@ bool WorldRenderActiveRecently(); 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. From 365394fe48e4105ba51a1001236875b3cbe21651 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:55:50 +0300 Subject: [PATCH 4/4] Default the low-latency presentation preset on and texture swaps off --- src/ac6_texture_overrides.cpp | 8 +++++--- src/main.cpp | 12 ++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) 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")) {