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");