Add 60fps cutscene clamp for in-engine cinematics

Suspend the FPS unlock while a demo-manager Exec (DD sub_82184460 / EM sub_821856F8) ticks, so the frame-locked IngameCinematics Sequencer plays at native ~30fps instead of double speed. Adds ac6_cutscene_clamp CVar (default on).
This commit is contained in:
salh
2026-06-15 16:03:43 +03:00
parent 0d7a528395
commit c2e2fbfbbc
27 changed files with 620 additions and 291 deletions
+5
View File
@@ -25,6 +25,7 @@ REXCVAR_DEFINE_BOOL(ac6_force_safe_draw_resolution_scale, true, "AC6/NativeGraph
"Force AC6 hybrid backend fixes mode to use 1x draw resolution scaling until the scaled path is fixed");
REXCVAR_DEFINE_BOOL(ac6_force_safe_direct_host_resolve, true, "AC6/NativeGraphics",
"Force AC6 hybrid backend fixes mode to keep direct_host_resolve disabled until the AC6 crash is fixed");
REXCVAR_DECLARE(std::string, ac6_graphics_backend);
namespace ac6::graphics {
namespace {
@@ -69,6 +70,9 @@ void SyncRuntimeFlags() {
g_runtime_status.authoritative_renderer_active =
g_runtime_status.enabled &&
g_runtime_status.mode != GraphicsRuntimeMode::kDisabled;
if (g_runtime_status.authoritative_renderer_name.empty()) {
g_runtime_status.authoritative_renderer_name = REXCVAR_GET(ac6_graphics_backend);
}
}
} // namespace
@@ -140,6 +144,7 @@ void OnFrameBoundary(rex::memory::Memory* memory) {
auto* kernel_state = ts->context()->kernel_state;
if (auto* concrete_graphics =
dynamic_cast<rex::graphics::GraphicsSystem*>(kernel_state->graphics_system())) {
g_runtime_status.authoritative_renderer_name = concrete_graphics->name();
concrete_graphics->GetLastSwapSubmission(&swap_submission, &swap_sequence);
guest_vblank_interval_ticks = concrete_graphics->guest_vblank_interval_ticks();
last_guest_vblank_tick = concrete_graphics->last_vblank_interrupt_guest_tick();
+2
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <rex/memory.h>
@@ -23,6 +24,7 @@ struct NativeGraphicsRuntimeStatus {
GraphicsRuntimeMode mode = GraphicsRuntimeMode::kHybridBackendFixes;
bool capture_enabled = false;
bool authoritative_renderer_active = false;
std::string authoritative_renderer_name;
uint32_t draw_resolution_scale_x = 1;
uint32_t draw_resolution_scale_y = 1;
bool direct_host_resolve = true;
+5 -2
View File
@@ -38,8 +38,11 @@ void NativeGraphicsStatusDialog::OnDraw(ImGuiIO& io) {
ImGui::Text("mode: %.*s", static_cast<int>(ToString(status.mode).size()),
ToString(status.mode).data());
ImGui::Text("authoritative renderer: %s",
status.authoritative_renderer_active ? "RexGlue/Xenia D3D12 backend"
: "disabled");
status.authoritative_renderer_active
? (status.authoritative_renderer_name.empty()
? "RexGlue/Xenia backend"
: status.authoritative_renderer_name.c_str())
: "disabled");
ImGui::Text("capture active: %s", status.capture_enabled ? "yes" : "no");
ImGui::Text("draw resolution scale: %ux%u", status.draw_resolution_scale_x,
status.draw_resolution_scale_y);
+5 -1
View File
@@ -7,7 +7,11 @@
#include <string_view>
#include <vector>
#include <rex/ui/d3d12/d3d12_api.h>
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <d3d12.h>
#include <dxgiformat.h>
namespace ac6::textures {
+31
View File
@@ -1,13 +1,24 @@
#pragma once
#include <memory>
#include <string>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/rex_app.h>
#if REX_HAS_D3D12
#include <rex/graphics/d3d12/graphics_system.h>
#endif
#if REX_HAS_VULKAN
#include <rex/graphics/vulkan/graphics_system.h>
#endif
#include "ac6_native_graphics.h"
#include "ac6_native_graphics_overlay.h"
#include "generated/ac6recomp_config.h"
REXCVAR_DECLARE(std::string, ac6_graphics_backend);
class Ac6recompApp : public rex::ReXApp {
public:
using rex::ReXApp::ReXApp;
@@ -27,6 +38,26 @@ class Ac6recompApp : public rex::ReXApp {
void OnPreSetup(rex::RuntimeConfig& config) override {
REXLOG_INFO("Ac6recompApp::OnPreSetup");
rex::ReXApp::OnPreSetup(config);
const std::string requested_backend = REXCVAR_GET(ac6_graphics_backend);
#if REX_HAS_VULKAN
if (requested_backend == "vulkan" || requested_backend == "auto") {
config.graphics =
REX_GRAPHICS_BACKEND(rex::graphics::vulkan::VulkanGraphicsSystem);
REXLOG_INFO("Ac6recompApp: selected Vulkan graphics backend");
return;
}
#endif
#if REX_HAS_D3D12
if (requested_backend == "d3d12" || requested_backend == "auto") {
config.graphics =
REX_GRAPHICS_BACKEND(rex::graphics::d3d12::D3D12GraphicsSystem);
REXLOG_INFO("Ac6recompApp: selected D3D12 graphics backend");
return;
}
#endif
REXLOG_WARN("Ac6recompApp: requested graphics backend '{}' is not available in this build",
requested_backend);
}
void OnPostSetup() override {
+18
View File
@@ -27,7 +27,9 @@ REXCVAR_DECLARE(bool, ac6_texture_swaps_dump_enabled);
REXCVAR_DECLARE(bool, vsync);
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);
#endif
REXCVAR_DECLARE(bool, vfetch_index_rounding_bias);
REXCVAR_DECLARE(int32_t, video_mode_width);
REXCVAR_DECLARE(int32_t, video_mode_height);
@@ -35,6 +37,20 @@ REXCVAR_DECLARE(std::string, resolution);
REXCVAR_DECLARE(int32_t, window_width);
REXCVAR_DECLARE(int32_t, window_height);
#if REX_HAS_VULKAN
#define AC6_DEFAULT_GRAPHICS_BACKEND "vulkan"
#elif REX_HAS_D3D12
#define AC6_DEFAULT_GRAPHICS_BACKEND "d3d12"
#else
#define AC6_DEFAULT_GRAPHICS_BACKEND "auto"
#endif
REXCVAR_DEFINE_STRING(ac6_graphics_backend, AC6_DEFAULT_GRAPHICS_BACKEND,
"AC6/NativeGraphics",
"Host graphics backend: vulkan, d3d12, or auto")
.allowed({"vulkan", "d3d12", "auto"})
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
REXCVAR_DEFINE_BOOL(ac6_performance_mode, true, "AC6/Performance",
"Disable all diagnostics, logging, and development overlays for maximum runtime performance");
@@ -82,9 +98,11 @@ void ApplyAc6DefaultSettings() {
if (!rex::cvar::HasNonDefaultValue("host_present_from_non_ui_thread")) {
REXCVAR_SET(host_present_from_non_ui_thread, false);
}
#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);
}
#endif
if (!rex::cvar::HasNonDefaultValue("vfetch_index_rounding_bias")) {
REXCVAR_SET(vfetch_index_rounding_bias, true);
}
+45 -1
View File
@@ -11,6 +11,9 @@
REXCVAR_DEFINE_BOOL(ac6_unlock_fps, false, "AC6", "Unlock frame rate to 60fps");
REXCVAR_DEFINE_BOOL(ac6_timing_hooks_enabled, true, "AC6",
"Enable AC6 timing hooks that alter the game's presentation cadence");
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");
using Clock = std::chrono::steady_clock;
@@ -21,12 +24,44 @@ std::atomic<double> g_fps{0.0};
std::atomic<uint64_t> g_frame_count{0};
Clock::time_point g_frame_start{};
// Wall-clock (steady) ms of the last in-engine cutscene tick. The cutscene
// hook (ac6CinematicTickHook) runs on the game thread; the timing hooks read
// this on the present/GPU path, hence the atomic. INT64_MIN = never ticked.
std::atomic<int64_t> g_last_cinematic_tick_ms{INT64_MIN};
// The demo-manager Exec runs every game frame while a cutscene plays (~16-33ms
// apart). A few-frame decay keeps the clamp asserted across the cutscene and
// auto-releases shortly after it ends.
constexpr int64_t kCinematicDecayMs = 100;
int64_t NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch())
.count();
}
bool AreTimingHooksActive() {
return REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps);
return REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps) &&
!ac6::IsCinematicActive();
}
} // namespace
namespace ac6 {
bool IsCinematicActive() {
if (!REXCVAR_GET(ac6_cutscene_clamp)) {
return false;
}
const int64_t last = g_last_cinematic_tick_ms.load(std::memory_order_relaxed);
if (last == INT64_MIN) {
return false;
}
return (NowMs() - last) <= kCinematicDecayMs;
}
} // namespace ac6
bool ac6FlipIntervalHook() {
return AreTimingHooksActive();
}
@@ -61,6 +96,15 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
g_frame_start = now;
}
void ac6CinematicTickHook(PPCRegister& /*r3*/) {
// A demo-manager Exec (DD: sub_82184460 / EM: sub_821856F8) ran this frame ->
// an in-engine cutscene is playing. Stamp the time so AreTimingHooksActive()
// suspends the 60fps unlock until the cutscene ends (the stamp goes stale a
// few frames after the last tick), letting the frame-locked cinematic
// Sequencer play at native ~30fps instead of double speed.
g_last_cinematic_tick_ms.store(NowMs(), std::memory_order_relaxed);
}
namespace ac6 {
FrameStats GetFrameStats() {
+10
View File
@@ -7,6 +7,7 @@
REXCVAR_DECLARE(bool, ac6_unlock_fps);
REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled);
REXCVAR_DECLARE(bool, ac6_cutscene_clamp);
namespace ac6 {
@@ -18,9 +19,18 @@ struct FrameStats {
FrameStats GetFrameStats();
// 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.
bool IsCinematicActive();
} // namespace ac6
bool ac6FlipIntervalHook();
bool ac6PresentIntervalHook(PPCRegister& r10);
void ac6DeltaDivisorHook(PPCRegister& r29);
void ac6PresentTimingHook(PPCRegister& r31);
// Fires once per frame from the demo-manager Exec while a cutscene is playing.
// r3 = the demo-manager `this`; unused (presence-of-call is the signal).
void ac6CinematicTickHook(PPCRegister& r3);