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).
This commit is contained in:
Dipshet
2026-07-09 22:33:39 +03:00
parent b627fc8eca
commit 175480efb7
4 changed files with 185 additions and 0 deletions
+1
View File
@@ -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)
@@ -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 <atomic>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/memory/utils.h>
#include <rex/ppc.h>
#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<float>(base + ea);
if (post == pre) {
return;
}
rex::memory::store_and_swap<float>(base + ea, static_cast<float>(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<float>(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<float>(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);
}
+8
View File
@@ -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;
+5
View File
@@ -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.