Merge pull request #26 from Dipshet/fix-jitter

Fix jitter and some QoL additions
This commit is contained in:
sal063
2026-07-14 21:50:05 +03:00
committed by GitHub
24 changed files with 686 additions and 89 deletions
+13 -14
View File
@@ -91,11 +91,6 @@
#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
@@ -112,12 +107,13 @@ constexpr double kNativeFrameMs = 1000.0 / 30.0;
// 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.
// shrinking as the frame rate rises. Below 30fps it keeps growing (up to the
// ac6_min_sim_fps floor via ac6::ClampedMinSimFps) so the per-frame dynamics
// track the raised delta clamp instead of freezing at the 30fps step. Gated on
// the SAME signal as the frame-delta hooks (ac6::TimingHooksActive) so the
// dynamics 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.
double StepRatio() {
static uint64_t s_cached_frame = ~uint64_t(0);
static double s_cached_ratio = 1.0;
@@ -127,15 +123,18 @@ double StepRatio() {
}
s_cached_frame = stats.frame_count;
s_cached_ratio = 1.0;
if (!REXCVAR_GET(ac6_fps_physics_fix) || !ac6::TimingHooksActive()) {
// Framerate-independence is part of the unlock; gate on the unlock alone
// (was the separate ac6_fps_physics_fix cvar, now folded in).
if (!ac6::TimingHooksActive()) {
return s_cached_ratio;
}
if (stats.frame_time_ms <= 0.0) {
return s_cached_ratio; // no frame measured yet
}
const double ratio_cap = 30.0 / ac6::ClampedMinSimFps();
double ratio = stats.frame_time_ms / kNativeFrameMs;
if (ratio > 1.0) {
ratio = 1.0; // never blend past the native 30fps step
if (ratio > ratio_cap) {
ratio = ratio_cap;
} else if (ratio < 0.02) {
ratio = 0.02; // sanity floor against a bogus frame-time sample
}
+12
View File
@@ -15,6 +15,7 @@
#include "ac6_native_graphics.h"
#include "ac6_native_graphics_overlay.h"
#include "render_hooks.h"
#include "generated/ac6recomp_config.h"
REXCVAR_DECLARE(std::string, ac6_graphics_backend);
@@ -78,6 +79,17 @@ class Ac6recompApp : public rex::ReXApp {
native_graphics_status_dialog_ =
std::make_unique<ac6::graphics::NativeGraphicsStatusDialog>(drawer);
native_graphics_status_dialog_->Show();
// Feed the F3 debug overlay the game's own frame stats so its guest
// frametime graph and counter reflect the real sim cadence (SDK cannot
// reach into the AC6 app layer, so it takes them through this callback).
// Must be here, not OnPostSetup: the debug overlay is created just before
// OnCreateDialogs, but OnPostSetup runs BEFORE the overlay exists, so a
// provider set there is silently dropped.
SetGuestFrameStats([]() -> rex::ui::FrameStats {
const ::ac6::FrameStats s = ::ac6::GetFrameStats();
return rex::ui::FrameStats{s.frame_time_ms, s.fps, s.frame_count};
});
}
private:
+3 -2
View File
@@ -7,7 +7,6 @@
#include <rex/logging.h>
REXCVAR_DECLARE(bool, ac6_render_capture);
REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled);
REXCVAR_DECLARE(bool, ac6_unlock_fps);
REXCVAR_DECLARE(bool, ac6_native_graphics_enabled);
REXCVAR_DECLARE(bool, ac6_force_safe_draw_resolution_scale);
@@ -218,7 +217,9 @@ std::unique_ptr<rex::ui::WindowedApp> Ac6recompAppCreate(rex::ui::WindowedAppCon
if (!rex::cvar::HasNonDefaultValue("log_level")) {
REXCVAR_SET(log_level, "debug");
}
REXCVAR_SET(ac6_unlock_fps, false);
// Smooth 60fps unlock is on by default now (zero-config for players); the
// toml can still set ac6_unlock_fps=false for stock locked behaviour.
REXCVAR_SET(ac6_unlock_fps, true);
ApplyAc6DefaultSettings();
ApplyAc6HybridStartupSafetyOverrides();
ApplyAc6FixDefaults();
+173 -24
View File
@@ -6,14 +6,25 @@
#include <chrono>
#include <cmath>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
#include <native/thread.h>
#include <rex/cvar.h>
#include <rex/graphics/graphics_system.h>
#include <rex/logging.h>
#include <rex/system/kernel_state.h>
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_unlock_fps, true, "AC6",
"Master switch for the smooth 60fps unlock (modern present pacing + "
"dt-snap + physics dt-correction). On by default; false = stock behaviour.");
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");
@@ -23,14 +34,90 @@ 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)");
REXCVAR_DEFINE_DOUBLE(ac6_fps_target, 60.0, "AC6",
"The rate the simulation + presentation run at under the FPS unlock. "
">0 = that exact rate (clamped to 30..ac6_max_sim_fps). "
"0 = AUTO: the largest rate <= ac6_max_sim_fps that evenly divides your "
"monitor's refresh, so frames land on refresh boundaries instead of "
"beating against them (240Hz->60, 144Hz->48, 120Hz->60, 60Hz->60). "
"Pick a target that divides your refresh; auto does this for you. "
"Mirrors PA's native PC engine, which has no separate cap and simply "
"runs the (dt-correct) sim at the display rate.");
REXCVAR_DEFINE_DOUBLE(ac6_max_sim_fps, 60.0, "AC6",
"Ceiling on the simulation/pacing rate. AC6's physics is only validated "
"to 60fps; above it, fixed-step assumptions surface (untested regime). "
"Both ac6_fps_target and the auto (refresh-matched) target are clamped to "
"this. Raise only once the >60 regime has been validated.");
REXCVAR_DEFINE_BOOL(ac6_dt_snap, true, "AC6",
"With the FPS unlock active, snap the per-frame simulation delta to the "
"EXACT pacing target when the real frame time is within ~1ms of it, so "
"the fixed-step integrator sees a constant step. Removes the residual "
"per-frame delta jitter that a fixed-step sim turns into visible shake; "
"genuine slowdowns fall through to the precision path.");
REXCVAR_DEFINE_DOUBLE(ac6_min_sim_fps, 20.0, "AC6",
"Lowest framerate the simulation runs at true speed before the "
"game's frame-delta clamp forces slow motion. The stock game floors "
"at 30 (below 30fps it plays in slow motion); this lowers the floor "
"(default 20) so a sub-30fps dip runs at correct speed - just "
"choppier - instead of slowing down and rubber-banding on recovery. "
"Set 30 for stock behavior. Active with the FPS unlock; drives the "
"frame-delta clamp and the physics step cap together so dynamics "
"stay consistent with the kinematics.");
using Clock = std::chrono::steady_clock;
// Current monitor refresh rate in Hz, 0 if unknown. Cached on first use (a
// mid-session refresh change is rare enough to need a restart).
static double HostRefreshHz() {
static double cached = -1.0;
if (cached >= 0.0) {
return cached;
}
cached = 0.0;
#if defined(_WIN32)
DEVMODEW dm;
ZeroMemory(&dm, sizeof(dm));
dm.dmSize = sizeof(dm);
if (EnumDisplaySettingsW(nullptr, ENUM_CURRENT_SETTINGS, &dm) && dm.dmDisplayFrequency > 1) {
cached = double(dm.dmDisplayFrequency);
}
#endif
return cached;
}
// Single source of truth for the sim/presentation rate under the unlock, so the
// pacing limiter and the dt-snap always agree. See ac6_fps_target / ac6_max_sim_fps.
static double ResolvePacingTargetFps() {
// Absolute floor (10) lets sub-30 rates be targeted for testing; below
// ac6_min_sim_fps the sim goes slow-mo (expected), but the rate is honored.
constexpr double kMinTargetFps = 10.0;
double ceil_fps = REXCVAR_GET(ac6_max_sim_fps);
if (ceil_fps < kMinTargetFps) ceil_fps = kMinTargetFps;
if (ceil_fps > 240.0) ceil_fps = 240.0;
double target = REXCVAR_GET(ac6_fps_target);
if (target > 0.0) {
if (target < kMinTargetFps) target = kMinTargetFps;
if (target > ceil_fps) target = ceil_fps;
return target;
}
// Auto: largest integer in [kMinTargetFps, ceil] that evenly divides the
// refresh rate, so frames align to refresh boundaries instead of beating.
const double refresh = HostRefreshHz();
if (refresh < kMinTargetFps) {
return ceil_fps; // unknown refresh: just run at the ceiling
}
const int hz = int(refresh + 0.5);
for (int f = int(ceil_fps + 0.5); f >= int(kMinTargetFps); --f) {
if (hz % f == 0) {
return double(f);
}
}
// No clean divisor >= 30 (e.g. 50/75Hz): fall back to min(refresh, ceiling).
return refresh < ceil_fps ? refresh : ceil_fps;
}
namespace {
std::atomic<double> g_frame_time_ms{0.0};
@@ -77,7 +164,7 @@ bool AreTimingHooksActive() {
// 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) &&
return REXCVAR_GET(ac6_unlock_fps) &&
!ac6::IsCinematicActive() &&
(!REXCVAR_GET(ac6_dynamic_vblank) || IsWorldRenderActive());
}
@@ -94,6 +181,16 @@ bool TimingHooksActive() {
return AreTimingHooksActive();
}
double ClampedMinSimFps() {
double min_fps = REXCVAR_GET(ac6_min_sim_fps);
if (min_fps < 10.0) {
min_fps = 10.0;
} else if (min_fps > 30.0) {
min_fps = 30.0; // can't floor above 30fps - that would slow 30fps content
}
return min_fps;
}
bool WorldRenderActiveRecently() {
return IsWorldRenderActive();
}
@@ -147,29 +244,60 @@ void ac6DeltaPrecisionHook(PPCRegister& r8, PPCRegister& r10, PPCRegister& r29,
s_remainder = 0.0;
return;
}
if (!REXCVAR_GET(ac6_delta_precision)) {
const bool precision = true; // always on now (frame-delta correctness)
const double min_fps = ac6::ClampedMinSimFps();
const bool raise_floor = min_fps < 30.0;
if (!precision && !raise_floor) {
s_remainder = 0.0;
return;
}
const double max_delta = 100.0; // the game's stock 30fps delta clamp
// The delta clamp becomes 3000/min_fps instead of the stock 100 (30fps->100,
// 20fps->150): the sim keeps true speed down to min_fps before slow motion.
const double max_delta = 3000.0 / min_fps;
// 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.
// same ticks-per-frame divisor (r10 = freq / 30) the game divided by. Clamp
// to the floor: time slower than min_fps is dropped, not banked.
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;
// dt-snap: at a steady framerate near the pacing target, force the delta to the
// EXACT target value so the fixed-step integrator sees a constant step. This is
// what removes the residual per-frame jitter (the aircraft shake) that survives
// smooth pacing - a fixed-step sim amplifies even sub-millisecond dt variance.
// Only engages within a tight window of the target; genuine slowdowns/speedups
// fall through to the precision path below. Mirrors PA's native PC engine, which
// feeds a constant dt at a steady rate. The target matches the pacing limiter
// exactly (shared ResolvePacingTargetFps), so sim and presentation stay locked.
if (REXCVAR_GET(ac6_dt_snap)) {
const double target_fps = ResolvePacingTargetFps();
const double target_delta = 3000.0 / target_fps; // game units, 100 = 30fps
const double tol_units = 3.0; // ~1ms window (100 delta-units = 33.3ms, so 1ms = 3)
if (tol_units > 0.0 && target_delta <= max_delta &&
std::fabs(exact - target_delta) < tol_units) {
double snapped = std::floor(target_delta + 0.5);
if (snapped < 1.0) {
snapped = 1.0;
}
s_remainder = 0.0; // locked to target; no fractional carry
r8.u64 = uint64_t(snapped);
return;
}
}
// precision on: carry the remainder (no +1). precision off: keep the game's
// floor(exact)+1 integer. floor(exact + 1) == floor(exact) + 1.
const double base = precision ? (s_remainder + exact) : (exact + 1.0);
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
delta = 1.0; // the game guarantees progress every frame; on the precision
// path the overshoot is repaid through a negative remainder
} else if (delta > max_delta) {
delta = max_delta;
}
s_remainder = base - delta;
s_remainder = precision ? (base - delta) : 0.0;
r8.u64 = uint64_t(delta);
}
@@ -180,19 +308,35 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
// 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 unlock = 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.
// Frame pacing for this frame. Precedence:
// 1. frame-locked content (menus, cutscenes) -> fixed 60Hz vblank override;
// 2. gameplay with ac6_vblank_auto -> modern present pacing: the guest
// vblank free-runs (gates nothing) and the game's swap thread instead
// blocks on real GPU delivery plus an absolute-deadline limiter at
// ac6_fps_target. The game runs at min(target, real GPU throughput)
// with uniform frame times - no vblank grid, so a sub-target GPU can
// never alias into the 60/30 sub-harmonic staircase;
// 3. otherwise the plain cvar-driven vblank (previous behavior).
double override_hz = 0.0;
double pacing_target_hz = 0.0;
if (dynamic_pacing && !free_running) {
override_hz = 60.0;
} else if (free_running) {
// Force the guest vblank to free-run (non-gating) during gameplay so
// PaceGuestPresent is the SOLE gate, regardless of the vsync /
// guest_vblank_sync_to_refresh cvars (whose defaults would otherwise re-add
// a 60Hz gate). This makes the smooth unlock work with zero configuration.
override_hz = 1000.0;
pacing_target_hz = ResolvePacingTargetFps();
}
rex::graphics::GraphicsSystem::SetGuestVblankHzOverride(override_hz);
rex::graphics::GraphicsSystem::SetGuestPresentPacing(pacing_target_hz);
const auto now = Clock::now();
if (g_frame_start.time_since_epoch().count() != 0) {
@@ -205,18 +349,23 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
}
g_frame_start = now;
// Log the first handful of pacing transitions.
// Log the first handful of pacing transitions (smooth pacing keyed negative
// so mode switches log distinctly from plain overrides).
const double log_key = pacing_target_hz > 0.0 ? -pacing_target_hz : override_hz;
static double last_log_key = -0.5;
static uint32_t transition_logs = 0;
if (unlock && override_hz != last_log_key && transition_logs < 32) {
if (unlock && log_key != last_log_key && transition_logs < 32) {
++transition_logs;
if (override_hz == 0.0) {
if (pacing_target_hz > 0.0) {
REXLOG_INFO("[AC6-VBLANK] pacing -> modern (present-paced, target {:.0f}fps)",
pacing_target_hz);
} else 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;
last_log_key = log_key;
}
void ac6CinematicTickHook(PPCRegister& /*r3*/) {
+6 -1
View File
@@ -6,7 +6,6 @@
#include <rex/ppc/types.h>
REXCVAR_DECLARE(bool, ac6_unlock_fps);
REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled);
REXCVAR_DECLARE(bool, ac6_cutscene_clamp);
namespace ac6 {
@@ -24,6 +23,12 @@ FrameStats GetFrameStats();
// off this so it stays in lockstep with the frame-delta hooks.
bool TimingHooksActive();
// ac6_min_sim_fps clamped to its supported range [10, 30]. Single source for
// the low-fps floor shared by the frame-delta clamp (ac6DeltaPrecisionHook)
// and the physics step cap (StepRatio) - the two must saturate at the same
// framerate or the dynamics desync from the kinematics below 30fps.
double ClampedMinSimFps();
// 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.
+11
View File
@@ -136,6 +136,17 @@ add_compile_definitions(REXGLUE_BUILD_CONFIG="$<CONFIG>")
# Generate version header -- must run after REX_PLATFORM is set
# Timestamp is set at configure time -- intentionally not updated on every rebuild
string(TIMESTAMP REXGLUE_BUILD_TIMESTAMP "%Y%m%d_%H%M")
# Short git commit hash of the containing project (re-run cmake to refresh it).
execute_process(
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE REXGLUE_GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(NOT REXGLUE_GIT_HASH)
set(REXGLUE_GIT_HASH "nogit")
endif()
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/include/rex/version.h.in
${CMAKE_CURRENT_BINARY_DIR}/include/rex/version.h
@@ -109,6 +109,23 @@ class GraphicsSystem : public system::IGraphicsSystem {
static void SetGuestVblankHzOverride(double hz);
static double GetGuestVblankHzOverride();
// Modern present pacing: while target_hz > 0, PaceGuestPresent (called by
// VdSwap on the guest thread that submits swaps) blocks that thread until
// every previously issued swap has been delivered to the presenter (GPU
// backpressure, frame latency 1), then rate-limits it to target_hz with an
// absolute-deadline limiter. The game then runs at min(target, real GPU
// throughput) with uniform frame times - the modern game loop - instead of
// quantizing to vblank-grid sub-harmonics (60/30/20) when the GPU cannot
// hold the target. Combine with a free-running guest vblank so the vblank
// wait never gates. target_hz 0 = off. Process-wide, not per-instance.
static void SetGuestPresentPacing(double target_hz);
// Called by VdSwap on the swapping guest thread before it submits the next
// swap; blocks per SetGuestPresentPacing. No-op while pacing is off.
static void PaceGuestPresent();
// Called by the command processor whenever a guest frame is actually
// delivered to the presenter (guest output refreshed for a swap).
static void NotifyGuestPresent();
bool Save(::rex::stream::ByteStream* stream);
bool Restore(::rex::stream::ByteStream* stream);
+2
View File
@@ -23,6 +23,7 @@
#include <rex/ui/imgui_dialog.h>
#include <rex/ui/imgui_drawer.h>
#include <rex/ui/immediate_drawer.h>
#include <rex/ui/overlay/build_stamp_overlay.h>
#include <rex/ui/overlay/debug_overlay.h>
#include <rex/ui/window.h>
#include <rex/ui/window_listener.h>
@@ -139,6 +140,7 @@ class ReXApp : public ui::WindowedApp, public ui::WindowListener, public ui::Win
// Built-in overlays
std::shared_ptr<LogCaptureSink> log_sink_;
std::unique_ptr<ui::DebugOverlayDialog> debug_overlay_;
std::unique_ptr<ui::BuildStampOverlay> build_stamp_overlay_;
std::unique_ptr<ui::ConsoleDialog> console_overlay_;
std::unique_ptr<ui::SettingsDialog> settings_overlay_;
};
@@ -0,0 +1,26 @@
/**
* @file rex/ui/overlay/build_stamp_overlay.h
* @brief Always-on build-stamp watermark (short git commit hash) drawn in
* the lower-left corner, independent of the F3 debug overlay.
*
* @copyright Copyright (c) 2026 Project Gracemeria
* @license BSD 3-Clause License
*/
#pragma once
#include <rex/ui/imgui_dialog.h>
namespace rex::ui {
// A non-interactive dialog that draws the build's short git commit hash in the
// lower-left corner every frame. Registers itself with the ImGuiDrawer on
// construction (like any ImGuiDialog). Drawing is gated by the show_build_stamp
// cvar (on by default); set it false to hide the watermark.
class BuildStampOverlay : public ImGuiDialog {
public:
explicit BuildStampOverlay(ImGuiDrawer* imgui_drawer);
protected:
void OnDraw(ImGuiIO& io) override;
};
} // namespace rex::ui
@@ -39,6 +39,44 @@ class DebugOverlayDialog : public ImGuiDialog {
private:
bool visible_ = false;
FrameStatsProvider stats_provider_;
// Frametime graph + throttled counter state.
static constexpr int kHistory = 240;
static constexpr double kCounterRefreshSeconds = 0.5;
// Ring buffers of frame times in milliseconds. Host = the presenter/paint
// cadence (what an external tool like RTSS would measure); guest = the
// game's own per-frame sim delta (where framerate-dependent judder lives).
float host_ms_history_[kHistory] = {};
float guest_ms_history_[kHistory] = {};
int host_history_pos_ = 0;
int guest_history_pos_ = 0;
uint64_t last_guest_frame_count_ = 0;
// Graph samples are pushed at a FIXED TIME interval (not per frame), so the
// graphs scroll at a consistent rate regardless of fps. kHistory samples then
// cover a fixed time window (240 * 1/60 s = ~4 s). Each sample is the max
// frametime since the last sample, so spikes between samples are preserved.
static constexpr double kGraphSampleSeconds = 1.0 / 60.0;
double graph_sample_accum_s_ = 0.0;
float graph_host_ms_max_ = 0.0f;
float graph_guest_ms_max_ = 0.0f;
// Counter averaging window (refreshed every kCounterRefreshSeconds so the
// numbers are readable instead of flickering every frame).
double counter_window_s_ = 0.0;
int host_frames_in_window_ = 0;
uint64_t guest_frames_at_window_start_ = 0;
double window_guest_ms_min_ = 1.0e9;
double window_guest_ms_max_ = 0.0;
// Held (displayed) counter values.
double disp_host_fps_ = 0.0;
double disp_host_ms_ = 0.0;
double disp_guest_fps_ = 0.0;
double disp_guest_ms_ = 0.0;
double disp_guest_ms_min_ = 0.0;
double disp_guest_ms_max_ = 0.0;
};
} // namespace rex::ui
+20
View File
@@ -0,0 +1,20 @@
/**
* @file rex/ui/present_stats.h
* @brief Lightweight presenter timing readouts for the debug overlay.
*
* @copyright Copyright (c) 2026 Project Gracemeria
* @license BSD 3-Clause License
*/
#pragma once
namespace rex::ui {
// Duration (ms) of the presenter's last swap-chain frame-latency wait - how long
// painting blocked on the GPU/display being ready to accept a new frame. A high,
// steady value means GPU-bound (the display/GPU is the bottleneck); ~0 means the
// pacing/limiter is the bottleneck (CPU-side), which is the healthy case. This is
// the analog of UnleashedRecomp's "Present Wait" profiler.
double GetLastPresentWaitMs();
void SetLastPresentWaitMs(double ms);
} // namespace rex::ui
+5
View File
@@ -8,6 +8,7 @@
#define REXGLUE_BUILD_PLATFORM "@REX_PLATFORM@"
#define REXGLUE_BUILD_TIMESTAMP "@REXGLUE_BUILD_TIMESTAMP@"
#define REXGLUE_GIT_HASH "@REXGLUE_GIT_HASH@"
// Requires REXGLUE_BUILD_CONFIG compile definition
// Title: "[rexglue-v0.7.1-Release]"
@@ -18,3 +19,7 @@
#define REXGLUE_BUILD_STAMP \
"build: rexglue-v" REXGLUE_VERSION_STRING "-" REXGLUE_BUILD_PLATFORM \
"-" REXGLUE_BUILD_CONFIG "@" REXGLUE_BUILD_TIMESTAMP
// Watermark: "rexglue-v0.7.1-Release @5120188b" (short git commit hash)
#define REXGLUE_BUILD_HASH_STAMP \
"rexglue-v" REXGLUE_VERSION_STRING "-" REXGLUE_BUILD_CONFIG " @" REXGLUE_GIT_HASH
+25
View File
@@ -38,6 +38,12 @@ REXCVAR_DEFINE_STRING(log_level, "info", "Log",
REXCVAR_DEFINE_STRING(log_file, "", "Log", "Log file path (empty = auto sequential naming)");
REXCVAR_DEFINE_BOOL(log_new_file_per_launch, true, "Log",
"Start a fresh log each launch: delete the previous log_file (and its "
"rotations) so it is replaced, not appended-to, and the files don't stack "
"up. Only affects a fixed log_file name; the empty-log_file sequential path "
"is already per-launch.");
REXCVAR_DEFINE_BOOL(log_verbose, false, "Log", "Enable verbose logging (sets level to trace)")
.debug_only();
@@ -69,6 +75,20 @@ bool g_initialized = false;
std::mutex g_mutex;
LogConfig g_config;
// Delete the previous log (and its rotation siblings ac6recomp.1.log, .2.log, ...)
// so each launch starts one FRESH log file, replacing the old one, instead of
// appending across runs. Run once at logging init before the sink is created.
static void RemovePreviousLog(const std::filesystem::path& path, int max_files) {
std::error_code ec;
std::filesystem::remove(path, ec);
const std::filesystem::path dir = path.parent_path();
const std::string stem = path.stem().string();
const std::string ext = path.extension().string();
for (int i = 1; i <= max_files && i <= 64; ++i) {
std::filesystem::remove(dir / fmt::format("{}.{}{}", stem, i, ext), ec);
}
}
std::filesystem::path NextSequentialLogPath(const std::filesystem::path& logs_dir,
std::string_view app_name) {
std::filesystem::create_directories(logs_dir);
@@ -219,6 +239,11 @@ void InitLogging(const LogConfig& config) {
std::string resolved_path;
if (config.log_file) {
resolved_path = config.log_file;
// Fresh single log each launch (QoL): delete the previous one first so a
// fixed log_file is replaced, not appended-to, and files don't stack up.
if (!resolved_path.empty() && REXCVAR_GET(log_new_file_per_launch)) {
RemovePreviousLog(resolved_path, REXCVAR_GET(log_max_files));
}
} else if (!config.app_name.empty()) {
auto log_dir = config.log_dir.empty() ? std::filesystem::current_path() / "logs"
: std::filesystem::path(config.log_dir);
@@ -2322,7 +2322,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
// frame (will close the frame after this anyway, so can't write
// multiple times per frame).
REXGPU_ERROR("RefreshGuestOutput: checkpoint 1 (gamma block start)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 1 (gamma block start)");
if (!(use_pwl_gamma_ramp ? gamma_ramp_pwl_up_to_date_
: gamma_ramp_256_entry_table_up_to_date_)) {
uint32_t gamma_ramp_offset_bytes = use_pwl_gamma_ramp ? 256 * 4 : 0;
@@ -2377,7 +2377,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
: gamma_ramp_256_entry_table_up_to_date_) = true;
}
REXGPU_ERROR("RefreshGuestOutput: checkpoint 2 (descriptor heap allocation)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 2 (descriptor heap allocation)");
// Destination, source, and if bindful, gamma ramp.
ui::d3d12::util::DescriptorCpuGpuHandlePair apply_gamma_descriptors[3];
ui::d3d12::util::DescriptorCpuGpuHandlePair apply_gamma_descriptor_gamma_ramp;
@@ -2423,16 +2423,16 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
apply_gamma_dest_uav_desc.Texture2D.MipSlice = 0;
apply_gamma_dest_uav_desc.Texture2D.PlaneSlice = 0;
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3 (CreateUAV, CreateSRV)");
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.1 - CreateUAV");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3 (CreateUAV, CreateSRV)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.1 - CreateUAV");
device->CreateUnorderedAccessView(apply_gamma_dest, nullptr, &apply_gamma_dest_uav_desc,
apply_gamma_descriptors[0].first);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.2 - CreateSRV");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.2 - CreateSRV");
device->CreateShaderResourceView(swap_texture_resource, &swap_texture_srv_desc,
apply_gamma_descriptors[1].first);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.3 - PushTransitionBarrier");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.3 - PushTransitionBarrier");
if (using_native_swap_texture) {
PushTransitionBarrier(swap_texture_resource,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE,
@@ -2442,10 +2442,10 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE);
gamma_ramp_buffer_state_ = D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE;
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.4 - D3DSetComputeRootSignature");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.4 - D3DSetComputeRootSignature");
deferred_command_list_.D3DSetComputeRootSignature(apply_gamma_root_signature_.Get());
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.5 - ApplyGammaConstants");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.5 - ApplyGammaConstants");
ApplyGammaConstants apply_gamma_constants;
apply_gamma_constants.size[0] = guest_output_width;
apply_gamma_constants.size[1] = guest_output_height;
@@ -2453,7 +2453,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
UINT(ApplyGammaRootParameter::kConstants),
sizeof(apply_gamma_constants) / sizeof(uint32_t), &apply_gamma_constants, 0);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.6 - RootDescriptorTable dest/src/ramp");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.6 - RootDescriptorTable dest/src/ramp");
deferred_command_list_.D3DSetComputeRootDescriptorTable(
UINT(ApplyGammaRootParameter::kDestination), apply_gamma_descriptors[0].second);
deferred_command_list_.D3DSetComputeRootDescriptorTable(
@@ -2461,7 +2461,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
deferred_command_list_.D3DSetComputeRootDescriptorTable(
UINT(ApplyGammaRootParameter::kRamp), apply_gamma_descriptor_gamma_ramp.second);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.7 - Select pipeline (pwl={} fxaa={})", use_pwl_gamma_ramp, use_fxaa);
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.7 - Select pipeline (pwl={} fxaa={})", use_pwl_gamma_ramp, use_fxaa);
ID3D12PipelineState* apply_gamma_pipeline;
if (use_pwl_gamma_ramp) {
apply_gamma_pipeline = use_fxaa ? apply_gamma_pwl_fxaa_luma_pipeline_.Get()
@@ -2475,17 +2475,17 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
REXGPU_ERROR("RefreshGuestOutput: CRITICAL: apply_gamma_pipeline IS NULL!");
}
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.8 - SetExternalPipeline (ptr={:016X})", (uint64_t)apply_gamma_pipeline);
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.8 - SetExternalPipeline (ptr={:016X})", (uint64_t)apply_gamma_pipeline);
SetExternalPipeline(apply_gamma_pipeline);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.9 - SubmitBarriers");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.9 - SubmitBarriers");
SubmitBarriers();
REXGPU_ERROR("RefreshGuestOutput: checkpoint 3.10 - D3DDispatch");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 3.10 - D3DDispatch");
uint32_t group_count_x = (guest_output_width + 15) / 16;
uint32_t group_count_y = (guest_output_height + 7) / 8;
deferred_command_list_.D3DDispatch(group_count_x, group_count_y, 1);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 4 (post-dispatch)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 4 (post-dispatch)");
// Apply FXAA.
if (use_fxaa) {
@@ -2558,7 +2558,7 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
} else {
if (apply_gamma_dest_initial_state !=
ui::d3d12::D3D12Presenter::kGuestOutputInternalState) {
REXGPU_ERROR("RefreshGuestOutput: checkpoint 5 - WARNING: unexpected apply_gamma_dest_initial_state {:08X}, expected {:08X}",
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 5 - WARNING: unexpected apply_gamma_dest_initial_state {:08X}, expected {:08X}",
uint32_t(apply_gamma_dest_initial_state),
uint32_t(ui::d3d12::D3D12Presenter::kGuestOutputInternalState));
}
@@ -2574,19 +2574,25 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE,
D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE);
}
REXGPU_ERROR("RefreshGuestOutput: checkpoint 6 (SubmitBarriers pre-EndSubmission)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 6 (SubmitBarriers pre-EndSubmission)");
SubmitBarriers();
REXGPU_ERROR("RefreshGuestOutput: checkpoint 7 (EndSubmission)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 7 (EndSubmission)");
EndSubmission(true);
REXGPU_ERROR("RefreshGuestOutput: checkpoint 8 (return true)");
REXGPU_DEBUG("RefreshGuestOutput: checkpoint 8 (return true)");
return true;
});
if (refreshed) {
// A guest frame was actually delivered to the presenter - feed the
// delivery-paced present pacing (see GraphicsSystem::NotifyGuestPresent).
GraphicsSystem::NotifyGuestPresent();
}
// End the frame even if did not present for any reason (the image refresher
// was not called), to prevent leaking per-frame resources.
REXGPU_ERROR("IssueSwap: post-RefreshGuestOutput EndSubmission");
REXGPU_DEBUG("IssueSwap: post-RefreshGuestOutput EndSubmission");
EndSubmission(true);
REXGPU_ERROR("IssueSwap: complete");
REXGPU_DEBUG("IssueSwap: complete");
return refreshed;
}
+105
View File
@@ -13,10 +13,13 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <thread>
#include <utility>
#include <rex/cvar.h>
@@ -113,6 +116,23 @@ __declspec(dllexport) uint32_t AmdPowerXpressRequestHighPerformance = 1;
} // extern "C"
#endif // REX_PLATFORM_WIN32
namespace {
// Modern present pacing state (see SetGuestPresentPacing / PaceGuestPresent).
// The counters pair the guest's swap submissions (VdSwap) with the frames the
// command processor actually delivered to the presenter (NotifyGuestPresent).
// PaceGuestPresent blocks the swapping guest thread on that pairing plus an
// absolute-deadline frame limiter - GPU backpressure and a rate ceiling, the
// modern game loop - instead of pacing the guest off the vblank grid.
std::atomic<double> g_present_pacing_target_hz{0.0};
std::atomic<uint64_t> g_guest_present_count{0};
std::atomic<uint64_t> g_guest_swaps_issued{0};
std::mutex g_present_pacing_mutex;
std::condition_variable g_present_pacing_cv;
// Guarded by g_present_pacing_mutex.
std::chrono::steady_clock::time_point g_present_pacing_deadline{};
uint64_t g_present_delivery_at_last_timeout = UINT64_MAX;
} // namespace
GraphicsSystem::GraphicsSystem() : vsync_worker_running_(false) {}
GraphicsSystem::~GraphicsSystem() = default;
@@ -423,6 +443,91 @@ double GraphicsSystem::GetGuestVblankHzOverride() {
return g_guest_vblank_hz_override.load(std::memory_order_relaxed);
}
void GraphicsSystem::SetGuestPresentPacing(double target_hz) {
g_present_pacing_target_hz.store(target_hz, std::memory_order_relaxed);
}
void GraphicsSystem::NotifyGuestPresent() {
g_guest_present_count.fetch_add(1, std::memory_order_relaxed);
// Empty critical section closes the race with PaceGuestPresent's predicate
// check-then-wait, so the notify below cannot fall between them and be lost.
{ std::lock_guard<std::mutex> lock(g_present_pacing_mutex); }
g_present_pacing_cv.notify_all();
}
void GraphicsSystem::PaceGuestPresent() {
const double target_hz = g_present_pacing_target_hz.load(std::memory_order_relaxed);
if (target_hz <= 0.0) {
return;
}
// 1) Backpressure: before letting the game submit the next swap, wait until
// every previously issued swap has been delivered to the presenter (frame
// latency 1). This is what paces the game to the real GPU rate when it
// cannot hold target_hz - uniformly, with no vblank grid to alias against.
const uint64_t issued = g_guest_swaps_issued.load(std::memory_order_relaxed);
{
std::unique_lock<std::mutex> lock(g_present_pacing_mutex);
const auto pred = [&] {
return g_guest_present_count.load(std::memory_order_relaxed) >= issued;
};
const uint64_t delivered_now = g_guest_present_count.load(std::memory_order_relaxed);
// While delivery is known-stalled (swaps are not reaching the presenter -
// device loss, experimental swap paths), skip the wait entirely; the
// limiter below still runs, degrading this to a plain frame-rate cap.
const bool delivery_stalled = g_present_delivery_at_last_timeout != UINT64_MAX &&
delivered_now == g_present_delivery_at_last_timeout;
if (!delivery_stalled && !pred()) {
if (g_present_pacing_cv.wait_for(lock, std::chrono::milliseconds(100), pred)) {
g_present_delivery_at_last_timeout = UINT64_MAX;
} else {
// Timed out: forgive the whole backlog so one undelivered swap cannot
// park every future frame on this timeout.
g_present_delivery_at_last_timeout =
g_guest_present_count.load(std::memory_order_relaxed);
g_guest_swaps_issued.store(g_present_delivery_at_last_timeout,
std::memory_order_relaxed);
}
} else if (!delivery_stalled) {
g_present_delivery_at_last_timeout = UINT64_MAX;
}
}
g_guest_swaps_issued.fetch_add(1, std::memory_order_relaxed);
// 2) Ceiling: absolute-deadline limiter at target_hz. Deadlines advance by
// exactly one interval while the game keeps up (drift-free target rate) and
// re-anchor to now when it does not, so a slow stretch accrues no catch-up
// debt that would burst frames afterwards.
const auto interval = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(1.0 / target_hz));
auto now = std::chrono::steady_clock::now();
std::chrono::steady_clock::time_point wake;
{
std::lock_guard<std::mutex> lock(g_present_pacing_mutex);
if (g_present_pacing_deadline < now) {
g_present_pacing_deadline = now;
}
wake = g_present_pacing_deadline;
g_present_pacing_deadline = wake + interval;
}
// Coarse sleep to ~2ms short of the deadline, then spin the remainder for
// precision (OS sleep granularity is ~1ms and can overshoot).
while (true) {
now = std::chrono::steady_clock::now();
if (now >= wake) {
break;
}
const auto remaining = wake - now;
if (remaining > std::chrono::milliseconds(2)) {
rex::thread::Sleep(
std::chrono::duration_cast<std::chrono::milliseconds>(remaining) -
std::chrono::milliseconds(2));
} else {
std::this_thread::yield();
}
}
}
void GraphicsSystem::MarkVblank() {
// TODO: Enable profiling once ported
// SCOPE_profile_cpu_f("gpu");
+3 -1
View File
@@ -1029,7 +1029,9 @@ bool GetResolveInfo(const RegisterFile& regs, const memory::Memory& memory,
}
if (x0 >= x1 || y0 >= y1) {
REXGPU_WARN(
// Empty resolve region after clamp is normal (many guest resolves scissor to
// nothing); debug-level so it doesn't flood the log every frame.
REXGPU_DEBUG(
"Skipping empty resolve region after clamp/alignment: "
"scissored {} <= x < {}, {} <= y < {}; final {} <= x < {}, {} <= y < {}; "
"scissor {} <= x < {}, {} <= y < {}; surface_pitch={}",
@@ -430,6 +430,11 @@ void VdSwap_entry(ppc_pvoid_t buffer_ptr, // ptr into primary ringbuffer
assert(width);
assert(height);
// Modern present pacing (no-op unless enabled): block this guest thread on
// GPU delivery of the previous swap plus the target-rate limiter before the
// game may submit the next one. See GraphicsSystem::PaceGuestPresent.
rex::graphics::GraphicsSystem::PaceGuestPresent();
namespace xenos = rex::graphics::xenos;
xenos::xe_gpu_texture_fetch_t gpu_fetch;
+1
View File
@@ -17,6 +17,7 @@ set(REXUI_CORE_SOURCES
set(REXUI_OVERLAY_SOURCES
${REXGLUE_ROOT}/src/ui/overlay/debug_overlay.cpp
${REXGLUE_ROOT}/src/ui/overlay/build_stamp_overlay.cpp
${REXGLUE_ROOT}/src/ui/overlay/console_overlay.cpp
${REXGLUE_ROOT}/src/ui/overlay/settings_overlay.cpp
)
@@ -10,6 +10,7 @@
*/
#include <algorithm>
#include <chrono>
#include <climits>
#include <cmath>
#include <memory>
@@ -19,6 +20,7 @@
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/math.h>
#include <rex/ui/present_stats.h>
#include <native/ui/d3d12/d3d12_presenter.h>
#include <native/ui/d3d12/d3d12_provider.h>
#include <native/ui/d3d12/d3d12_util.h>
@@ -585,8 +587,12 @@ void D3D12Presenter::PaintContext::DestroySwapChain() {
Presenter::PaintResult D3D12Presenter::PaintAndPresentImpl(bool execute_ui_drawers) {
if (paint_context_.HasFrameLatencyWaitableObject()) {
auto wait_begin = std::chrono::steady_clock::now();
DWORD wait_result =
WaitForSingleObjectEx(paint_context_.swap_chain_latency_waitable_object, 1000, FALSE);
ui::SetLastPresentWaitMs(
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - wait_begin)
.count());
if (wait_result == WAIT_FAILED) {
REXLOG_WARN("D3D12Presenter: Waiting for the swap chain frame latency object failed");
} else if (wait_result == WAIT_TIMEOUT) {
+11
View File
@@ -10,6 +10,7 @@
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/platform.h>
#include <rex/ui/present_stats.h>
#include <native/ui/presenter.h>
#include <native/ui/window.h>
@@ -28,6 +29,16 @@ REXCVAR_DEFINE_BOOL(host_present_from_non_ui_thread, true, "UI/Presenter",
REXCVAR_DEFINE_BOOL(present_letterbox, true, "UI/Presenter",
"Enable letterboxing for non-native aspect ratios");
namespace rex::ui {
namespace {
std::atomic<double> g_last_present_wait_ms{0.0};
} // namespace
double GetLastPresentWaitMs() { return g_last_present_wait_ms.load(std::memory_order_relaxed); }
void SetLastPresentWaitMs(double ms) {
g_last_present_wait_ms.store(ms, std::memory_order_relaxed);
}
} // namespace rex::ui
REXCVAR_DEFINE_INT32(present_safe_area_x, 90, "UI/Presenter",
"Horizontal safe area percentage (0-100)")
.range(0, 100);
+32
View File
@@ -43,6 +43,13 @@
REXCVAR_DEFINE_STRING(user_data_root, "", "Runtime", "Override user data path");
REXCVAR_DEFINE_STRING(update_data_root, "", "Runtime", "Override update data path");
REXCVAR_DEFINE_BOOL(use_shader_disk_cache, true, "GPU",
"Pre-compile the game's GPU pipelines from a persistent on-disk cache at "
"launch (during load) and record new ones, so pipelines do not compile "
"on-demand mid-gameplay - the first-encounter stutter (e.g. at mission "
"start). First run of a scene records the pipelines; later runs load them. "
"Cache lives in ./cache/shaders/.");
namespace rex {
// --- ReXApp ---
@@ -216,6 +223,7 @@ bool ReXApp::OnInitialize() {
imgui_drawer_->SetPresenterAndImmediateDrawer(presenter, immediate_drawer_.get());
// Built-in overlays
debug_overlay_ = std::make_unique<ui::DebugOverlayDialog>(imgui_drawer_.get());
build_stamp_overlay_ = std::make_unique<ui::BuildStampOverlay>(imgui_drawer_.get());
console_overlay_ = std::make_unique<ui::ConsoleDialog>(imgui_drawer_.get(), log_sink_);
settings_overlay_ = std::make_unique<ui::SettingsDialog>(
imgui_drawer_.get(), config_path);
@@ -246,6 +254,29 @@ bool ReXApp::OnInitialize() {
return;
}
// Wire up the GPU pipeline disk cache. The backend has the whole shader
// storage system (record + pre-create), but nothing triggers it otherwise,
// so every run compiles pipelines on demand during gameplay - the
// first-encounter stutter (mission start). Init here, now that the title is
// loaded (title_id known) and the GPU is set up: blocking, so previously
// recorded pipelines are pre-created during the initial load rather than
// mid-play, and new ones are recorded for next time.
if (REXCVAR_GET(use_shader_disk_cache) && runtime_->graphics_system() &&
runtime_->kernel_state()) {
uint32_t shader_cache_title_id = runtime_->kernel_state()->title_id();
if (shader_cache_title_id != 0) {
std::filesystem::path cache_root = std::filesystem::current_path() / "cache";
REXLOG_INFO("Shader disk cache: initializing for title {:08X} at {}",
shader_cache_title_id, rex::path_to_utf8(cache_root));
// graphics_system() returns the IGraphicsSystem interface; InitializeShaderStorage
// lives on the concrete GraphicsSystem (always a rex::graphics::GraphicsSystem here).
static_cast<rex::graphics::GraphicsSystem*>(runtime_->graphics_system())
->InitializeShaderStorage(cache_root, shader_cache_title_id, true);
} else {
REXLOG_WARN("Shader disk cache: title_id unavailable, skipping");
}
}
module_thread_ = std::thread([this, main_thread = std::move(main_thread)]() mutable {
main_thread->Wait(0, 0, 0, nullptr);
REXLOG_INFO("Execution complete");
@@ -279,6 +310,7 @@ void ReXApp::OnDestroy() {
// ImGui cleanup (reverse of setup)
settings_overlay_.reset();
console_overlay_.reset();
build_stamp_overlay_.reset();
debug_overlay_.reset();
if (imgui_drawer_) {
imgui_drawer_->SetPresenterAndImmediateDrawer(nullptr, nullptr);
+1
View File
@@ -19,6 +19,7 @@ set(REXUI_CORE_SOURCES
# Optional overlay dialogs
set(REXUI_OVERLAY_SOURCES
overlay/debug_overlay.cpp
overlay/build_stamp_overlay.cpp
overlay/console_overlay.cpp
overlay/settings_overlay.cpp
)
@@ -0,0 +1,40 @@
/**
* @file ui/overlay/build_stamp_overlay.cpp
* @brief Always-on build-stamp watermark. See build_stamp_overlay.h.
*
* @copyright Copyright (c) 2026 Project Gracemeria
* @license BSD 3-Clause License
*/
#include <rex/ui/overlay/build_stamp_overlay.h>
#include <rex/cvar.h>
#include <rex/version.h>
#include <imgui.h>
REXCVAR_DEFINE_BOOL(show_build_stamp, true, "UI",
"Draw the build-stamp watermark (short git commit hash) in the "
"lower-left corner. On by default; set false to hide it.");
namespace rex::ui {
BuildStampOverlay::BuildStampOverlay(ImGuiDrawer* imgui_drawer) : ImGuiDialog(imgui_drawer) {}
void BuildStampOverlay::OnDraw(ImGuiIO& io) {
if (!REXCVAR_GET(show_build_stamp))
return;
const float margin = io.DisplaySize.y * 0.02f;
const ImVec2 size = ImGui::CalcTextSize(REXGLUE_BUILD_HASH_STAMP);
ImGui::SetNextWindowPos(ImVec2(margin, io.DisplaySize.y - size.y - margin));
ImGui::SetNextWindowSize(ImVec2(0, 0));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 0.5f));
if (ImGui::Begin("##buildstamp", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::TextUnformatted(REXGLUE_BUILD_HASH_STAMP);
}
ImGui::End();
ImGui::PopStyleColor();
}
} // namespace rex::ui
+105 -27
View File
@@ -11,9 +11,13 @@
*/
#include <rex/ui/overlay/debug_overlay.h>
#include <rex/ui/keybinds.h>
#include <rex/ui/present_stats.h>
#include <rex/version.h>
#include <imgui.h>
#include <algorithm>
#include <cstdio>
namespace rex::ui {
DebugOverlayDialog::DebugOverlayDialog(ImGuiDrawer* imgui_drawer, FrameStatsProvider stats_provider)
@@ -29,36 +33,110 @@ void DebugOverlayDialog::OnDraw(ImGuiIO& io) {
if (!visible_)
return;
ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(220, 60), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowBgAlpha(0.5f);
if (ImGui::Begin("Debug##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) {
ImGui::Text("Host: %.1f FPS (%.2f ms)", io.Framerate, 1000.0f / io.Framerate);
if (stats_provider_) {
auto stats = stats_provider_();
if (stats.frame_count > 0) {
ImGui::Text("Guest: %.1f FPS (%.2f ms)", stats.fps, stats.frame_time_ms);
}
}
}
ImGui::End();
// --- Sample this host frame --------------------------------------------
const double host_ms = io.DeltaTime * 1000.0;
// Build stamp watermark -- centered near bottom of screen
auto text_size = ImGui::CalcTextSize(REXGLUE_BUILD_STAMP);
float padding = ImGui::GetStyle().WindowPadding.x * 2.0f;
float bottom_offset = io.DisplaySize.y * 0.03f;
ImGui::SetNextWindowPos(ImVec2((io.DisplaySize.x - text_size.x - padding) * 0.5f,
io.DisplaySize.y - text_size.y - bottom_offset));
ImGui::SetNextWindowSize(ImVec2(0, 0));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 0.5f));
if (ImGui::Begin("##watermark", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::TextUnformatted(REXGLUE_BUILD_STAMP);
FrameStats stats;
bool have_guest = false;
if (stats_provider_) {
stats = stats_provider_();
have_guest = stats.frame_count > 0;
}
// Counter spread metric: track guest frametime extremes per ACTUAL guest frame.
if (have_guest && stats.frame_count != last_guest_frame_count_) {
last_guest_frame_count_ = stats.frame_count;
window_guest_ms_min_ = (std::min)(window_guest_ms_min_, stats.frame_time_ms);
window_guest_ms_max_ = (std::max)(window_guest_ms_max_, stats.frame_time_ms);
}
// Push graph samples at a FIXED TIME interval (kGraphSampleSeconds), not per
// frame, so both graphs scroll at a consistent rate regardless of fps. Each
// sample carries the max frametime since the last sample (spikes preserved).
graph_host_ms_max_ = (std::max)(graph_host_ms_max_, float(host_ms));
if (have_guest) {
graph_guest_ms_max_ = (std::max)(graph_guest_ms_max_, float(stats.frame_time_ms));
}
graph_sample_accum_s_ += io.DeltaTime;
if (graph_sample_accum_s_ >= kGraphSampleSeconds) {
bool first = true;
while (graph_sample_accum_s_ >= kGraphSampleSeconds) {
graph_sample_accum_s_ -= kGraphSampleSeconds;
// First push uses the accumulated max; extra pushes (a frame longer than
// several sample intervals - a hitch) repeat the current frametime so the
// graph fills the gap at the right time width instead of dropping to zero.
host_ms_history_[host_history_pos_] = first ? graph_host_ms_max_ : float(host_ms);
host_history_pos_ = (host_history_pos_ + 1) % kHistory;
guest_ms_history_[guest_history_pos_] =
first ? graph_guest_ms_max_ : (have_guest ? float(stats.frame_time_ms) : 0.0f);
guest_history_pos_ = (guest_history_pos_ + 1) % kHistory;
first = false;
}
graph_host_ms_max_ = 0.0f;
graph_guest_ms_max_ = 0.0f;
}
// --- Throttled counters (averaged over kCounterRefreshSeconds) ----------
counter_window_s_ += io.DeltaTime;
++host_frames_in_window_;
if (counter_window_s_ >= kCounterRefreshSeconds) {
disp_host_fps_ = host_frames_in_window_ / counter_window_s_;
disp_host_ms_ = disp_host_fps_ > 0.0 ? 1000.0 / disp_host_fps_ : 0.0;
if (have_guest) {
const double guest_frames = double(stats.frame_count - guest_frames_at_window_start_);
disp_guest_fps_ = guest_frames / counter_window_s_;
disp_guest_ms_ = disp_guest_fps_ > 0.0 ? 1000.0 / disp_guest_fps_ : 0.0;
disp_guest_ms_min_ = window_guest_ms_min_ < 1.0e8 ? window_guest_ms_min_ : 0.0;
disp_guest_ms_max_ = window_guest_ms_max_;
}
counter_window_s_ = 0.0;
host_frames_in_window_ = 0;
guest_frames_at_window_start_ = have_guest ? stats.frame_count : 0;
window_guest_ms_min_ = 1.0e9;
window_guest_ms_max_ = 0.0;
}
// --- Draw ---------------------------------------------------------------
ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(320, 0), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowBgAlpha(0.55f);
if (ImGui::Begin("Frame timing##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) {
ImGui::Text("Host: %6.1f FPS %5.2f ms", disp_host_fps_, disp_host_ms_);
// Host = the presenter's paint/present rate = what an external tool like RTSS
// measures. It is DECOUPLED from the guest game rate below (the presenter
// paints on the UI thread at ~monitor refresh), so Host >> Guest is expected
// and is why RTSS reads the present rate, not the game fps. Present-wait = how
// long paint blocked on the GPU/display (high = GPU-bound; ~0 = pacing-bound).
ImGui::SameLine();
ImGui::TextDisabled(" wait %.2f ms", GetLastPresentWaitMs());
if (have_guest) {
ImGui::Text("Guest: %6.1f FPS %5.2f ms", disp_guest_fps_, disp_guest_ms_);
// Spread over the window is the numeric shake metric: a wide min/max at a
// steady average fps is exactly the uneven-pacing / aircraft-shake signal.
ImGui::TextDisabled("guest ft min %.2f max %.2f spread %.2f ms", disp_guest_ms_min_,
disp_guest_ms_max_, disp_guest_ms_max_ - disp_guest_ms_min_);
}
ImGui::Separator();
// Both graphs share a fixed 0-40ms scale so host vs guest jaggedness is
// directly comparable. Flat host + jagged guest == sim-dt jitter (the shake
// is in the game's timing, not the presenter); jagged both == real delivery
// unevenness (look at the pacing/limiter).
char overlay[48];
std::snprintf(overlay, sizeof(overlay), "host %.2f ms (now)", host_ms);
ImGui::PlotLines("##host_ft", host_ms_history_, kHistory, host_history_pos_, overlay, 0.0f,
40.0f, ImVec2(0, 55));
if (have_guest) {
std::snprintf(overlay, sizeof(overlay), "guest %.2f ms (now)", stats.frame_time_ms);
ImGui::PlotLines("##guest_ft", guest_ms_history_, kHistory, guest_history_pos_, overlay,
0.0f, 40.0f, ImVec2(0, 55));
}
ImGui::TextDisabled("graphs 0-40 ms, ~%.0fs window (max/%.0fms) | counters avg %.1fs",
kHistory * kGraphSampleSeconds, kGraphSampleSeconds * 1000.0,
kCounterRefreshSeconds);
}
ImGui::End();
ImGui::PopStyleColor();
}
} // namespace rex::ui