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] 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.