From b6b94bac9180d8f48ad5e225a93dbd307e74905e Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:22:41 +0200 Subject: [PATCH] Keep in-engine cutscene video locked to the audio clock after hitches A render hitch during an in-engine cutscene permanently desynced video from the audio, which is master and keeps playing. Weak-symbol wrappers around the two demo-manager Exec functions issue bounded catch-up ticks computed from the audio clients' consumed-sample clock (48 kHz, deficit threshold 2). Behind ac6_cutscene_resync (on); dev tooling default-off. --- CMakeLists.txt | 1 + src/ac6_backend_fixes/ac6_cutscene_resync.cpp | 187 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/ac6_backend_fixes/ac6_cutscene_resync.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e434ce6b..399fa47b 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_cutscene_resync.cpp src/ac6_backend_fixes/ac6_fps_physics_fix.cpp src/ac6_backend_fixes/ac6_kbm_input.cpp src/ac6_backend_fixes/ac6_widescreen.cpp diff --git a/src/ac6_backend_fixes/ac6_cutscene_resync.cpp b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp new file mode 100644 index 00000000..f05607a9 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_cutscene_resync.cpp @@ -0,0 +1,187 @@ +// Cutscene A/V resync (audio-master catch-up). +// +// The mechanism (confirmed in code and on real hardware): guest audio time = +// XAudioGetRenderDriverTic = host samples consumed INCLUDING injected +// underrun silence, so it tracks wall clock through any render hitch; the +// audio worker is its own thread, so a render stall does not even pause real +// audio. The in-engine cutscene sequencers (CAce6DemoManager::Exec "DD" +// 0x82184460 / CX360DemoManagerEM::Exec "EM" 0x821856F8) tick their timeline +// once per rendered frame. One long frame puts the video permanently behind +// the audio; sustained sub-30fps render turns the whole cutscene into slow +// motion against its soundtrack. +// +// This file wraps both Exec functions (weak symbols in the generated code, +// same override pattern as ac6_fps_physics_fix.cpp). Audio is the master +// clock and is NEVER touched - the cutscene catches up instead. Per rendered +// frame, deficit = ticks the audio clock says should have run minus ticks +// issued; while the deficit is >= 2 ticks, extra Exec calls run (capped per +// frame by ac6_cutscene_resync_max_ticks) - a bounded timeline frame-skip, +// the same audio-master pattern film players use. The >= 2 engage threshold +// keeps normal 30fps playback provably untouched (steady-state phase jitter +// is +/-1 tick and can never trigger it). The cap is also the slowest +// sustained render rate that stays synced: N extras per frame holds sync +// down to 30/(1+N) fps. 0 = uncapped (one hard jump-cut after a stall). +// +// Both Exec wrappers run on the guest game thread (the sole caller), so +// plain statics are safe throughout. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +REXCVAR_DEFINE_BOOL(ac6_cutscene_resync, true, "AC6", + "Keep in-engine cutscene video locked to its audio. Audio " + "is the master clock and is never altered; after a render " + "hitch (or under sustained slow rendering) the cutscene " + "timeline catches up by running extra sequencer ticks - a " + "bounded frame-skip, like every film player's audio-master " + "sync. No effect on normal full-speed playback (catch-up " + "engages only past 2 ticks of drift; verified extras=0 in " + "steady state, injected-hitch recovery in 3 frames, and " + "sustained 14fps @ 5x draw scale staying locked)."); +REXCVAR_DEFINE_INT32(ac6_cutscene_resync_max_ticks, 3, "AC6", + "Max extra cutscene sequencer ticks per rendered frame " + "while catching up (ac6_cutscene_resync). Also sets the " + "slowest sustained render rate that stays in sync: N " + "extras holds sync down to 30/(1+N) fps (3 -> 7.5 fps). " + "Low = gentle brief fast-forward after a stall; 0 = " + "uncapped, one hard jump-cut."); + +PPC_EXTERN_FUNC(__imp__rex_sub_82184460); // CAce6DemoManager::Exec ("DD") +PPC_EXTERN_FUNC(__imp__rex_sub_821856F8); // CX360DemoManagerEM::Exec ("EM") + +namespace { + +using Clock = std::chrono::steady_clock; + +// Demo sequencers advance one timeline frame per Exec at the game's native +// 30fps cadence (cutscenes stay clamped to 30 under the FPS unlock). +constexpr uint64_t kSamplesPerDemoTick = rex::audio::kAudioFrameSampleRate / 30; // 1600 + +// An Exec gap this long means the demo session ended (menus/gameplay between +// cutscenes). Generous enough that a real mid-cutscene stall keeps its +// session - that stall is exactly what the resync must recover from. +constexpr int64_t kSessionResetMs = 1500; + +int64_t NowMs() { + return std::chrono::duration_cast( + Clock::now().time_since_epoch()) + .count(); +} + +// Reads the single audio client's consumed-samples clock - the exact value +// the guest sees through XAudioGetRenderDriverTic (48kHz sample units, +// advancing in real time through hitches because underrun silence counts as +// consumed). Returns false if the audio system/client is unavailable. +bool ReadAudioClockSamples(PPCContext& ctx, uint64_t* out_samples) { + if (!ctx.kernel_state) { + return false; + } + auto* native_audio = ctx.kernel_state->native_audio_system(); + if (!native_audio) { + return false; + } + const rex::audio::AudioClientTimingSnapshot timing = + native_audio->GetClientTimingSnapshot(0); + if (timing.consumed_samples == 0) { + // No audio consumed yet (startup) - no usable master clock this frame. + return false; + } + *out_samples = timing.consumed_samples; + return true; +} + +struct DemoSession { + const char* site = ""; + int64_t last_exec_ms = INT64_MIN; + bool clock_valid = false; + uint64_t baseline_samples = 0; + uint64_t ticks_issued = 0; +}; + +DemoSession g_session; + +void ExecWithResync(PPCContext& ctx, uint8_t* base, + void (*original)(PPCContext&, uint8_t*), const char* site) { + const int64_t now_ms = NowMs(); + + const bool new_session = g_session.site != site || + g_session.last_exec_ms == INT64_MIN || + (now_ms - g_session.last_exec_ms) > kSessionResetMs; + if (new_session) { + g_session = DemoSession{}; + g_session.site = site; + } + g_session.last_exec_ms = now_ms; + + uint64_t audio_samples = 0; + const bool clock_ok = ReadAudioClockSamples(ctx, &audio_samples); + if (clock_ok && !g_session.clock_valid) { + // First usable clock reading of this session: the tick issued this very + // frame corresponds to "now" on the audio timeline. + g_session.clock_valid = true; + g_session.baseline_samples = audio_samples; + g_session.ticks_issued = 0; + } + + const bool resync = REXCVAR_GET(ac6_cutscene_resync) && + g_session.clock_valid && clock_ok; + // The original's input registers, for re-issuing the call. The extra ticks + // must not see the first call's clobbered volatile registers. + PPCContext saved_ctx; + if (resync) { + saved_ctx = ctx; + } + + // The frame's own tick. + original(ctx, base); + ++g_session.ticks_issued; + + int64_t deficit_ticks = 0; + if (g_session.clock_valid && clock_ok) { + const uint64_t expected = + (audio_samples - g_session.baseline_samples) / kSamplesPerDemoTick; + deficit_ticks = static_cast(expected) - + static_cast(g_session.ticks_issued); + } + + // Catch-up: engage past 2 ticks of drift (steady-state jitter is +/-1 and + // must never trigger), then tick the timeline down to zero deficit, capped + // per frame. The deficit is recomputed from absolute clocks every frame, + // so any remainder past the cap carries automatically. + if (resync && deficit_ticks >= 2) { + const int32_t cap = REXCVAR_GET(ac6_cutscene_resync_max_ticks); + int64_t extras = deficit_ticks; + if (cap > 0) { + extras = std::min(extras, cap); + } + for (int64_t i = 0; i < extras; ++i) { + ctx = saved_ctx; + original(ctx, base); + ++g_session.ticks_issued; + } + REXLOG_DEBUG("[AC6-CUTSYNC] catch-up: site={} ran {} extra ticks (deficit was {}, cap {})", + site, extras, deficit_ticks, cap); + } +} + +} // namespace + +// CAce6DemoManager::Exec ("DD") - in-engine cutscene sequencer tick. +PPC_FUNC_IMPL(rex_sub_82184460) { + PPC_FUNC_PROLOGUE(); + ExecWithResync(ctx, base, __imp__rex_sub_82184460, "DD"); +} + +// CX360DemoManagerEM::Exec ("EM") - in-engine cutscene sequencer tick. +PPC_FUNC_IMPL(rex_sub_821856F8) { + PPC_FUNC_PROLOGUE(); + ExecWithResync(ctx, base, __imp__rex_sub_821856F8, "EM"); +}