Fix cvar preset timing and settings-save leaks; tidy the cvar surface

Config presets now run after the toml loads: the ac6_fix_* switches and
their scale gates finally honor user settings. The settings save persists
only user-set values, with toml escaping - no leaked presets, and a saved
Windows path can no longer void the whole config. Runtime-forced values
(performance mode) can no longer overwrite the user's saved choices.
Menu: wrapped tooltips, "(set by <cvar>)" on driven entries, a developer
toggle; AC6-added cvars grouped under the AC6 node as AC6/Fixes and
AC6/Enhancements, with true restart/live lifecycles.
This commit is contained in:
Dipshet
2026-08-06 15:46:15 +02:00
parent 9b81d7b7e6
commit deb406bdb0
16 changed files with 681 additions and 372 deletions
+7 -17
View File
@@ -36,23 +36,13 @@
#include <rex/ppc.h>
#include <rex/system/kernel_state.h>
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.");
REXCVAR_DEFINE_BOOL(ac6_cutscene_resync, true, "AC6/Fixes",
"Keep cutscene video locked to its audio after render hitches "
"by letting the timeline catch up (a bounded frame-skip).");
REXCVAR_DEFINE_INT32(ac6_cutscene_resync_max_ticks, 3, "AC6/Fixes",
"Max catch-up cutscene sequencer ticks per rendered frame "
"during resync. 0 = uncapped (one hard jump-cut).")
.debug_only();
PPC_EXTERN_FUNC(__imp__rex_sub_82184460); // CAce6DemoManager::Exec ("DD")
PPC_EXTERN_FUNC(__imp__rex_sub_821856F8); // CX360DemoManagerEM::Exec ("EM")
@@ -67,13 +67,9 @@
#include <rex/ppc.h>
REXCVAR_DEFINE_BOOL(
ac6_effect_mode_fix, false, "AC6",
"Fix the one-frame effects/clouds flicker under host CPU pressure. The effect update "
"and the effect draw are two async worker jobs sharing one vtable slot, told apart only "
"by a plain-store mode word with no per-job copy; when a host stall lets the second "
"submit's write land first, both jobs draw and the effect + cloud passes are emitted "
"twice. This snapshots the mode at submit and dispatches from the snapshot. Off by "
"default.");
ac6_effect_mode_fix, false, "AC6/Fixes",
"Fix the one-frame effects/clouds flicker under heavy CPU load by "
"snapshotting the effect job mode at submit. Off by default.");
// Dispatch targets, called directly. NOT via PPC_CALL_INDIRECT_FUNC: that macro
// degrades to __builtin_debugtrap() unless ppc_config.h was included first,
+11 -8
View File
@@ -67,14 +67,17 @@
#include <windows.h>
#endif
REXCVAR_DEFINE_BOOL(ac6_kbm_enabled, false, "AC6/KBM",
"Enable keyboard+mouse input (default off; set true in ac6recomp.toml)");
REXCVAR_DEFINE_BOOL(ac6_kbm_log, false, "AC6/KBM",
"Log KBM diagnostics to the game log (info level)");
REXCVAR_DEFINE_STRING(ac6_kbm_config, "ac6_input.toml", "AC6/KBM",
"Path to the keyboard+mouse binding config");
REXCVAR_DEFINE_BOOL(ac6_kbm_padless, true, "AC6/KBM",
"Present a synthetic pad on slot 0 when no controller is connected");
REXCVAR_DEFINE_BOOL(ac6_kbm_enabled, false, "AC6/Enhancements",
"Enable keyboard and mouse controls.");
REXCVAR_DEFINE_BOOL(ac6_kbm_log, false, "AC6/Enhancements",
"Log keyboard and mouse input diagnostics.")
.debug_only();
REXCVAR_DEFINE_STRING(ac6_kbm_config, "ac6_input.toml", "AC6/Enhancements",
"Path to the keyboard and mouse bindings file. Edits to the "
"file are picked up live.");
REXCVAR_DEFINE_BOOL(ac6_kbm_padless, true, "AC6/Enhancements",
"Provide a virtual controller when none is connected, so "
"keyboard and mouse work on their own.");
// The SDK's own mnk virtual-pad driver (input/mnk) - forced off while our
// KB+M is enabled so two keyboard mappers never fight over the same pad.
+6 -23
View File
@@ -50,29 +50,12 @@
#include "../render_hooks.h"
#include "ac6_widescreen.h"
REXCVAR_DEFINE_BOOL(ac6_widescreen, false, "AC6",
"Arbitrary aspect ratio (hor+), in-mission only. The target aspect "
"is derived from the actual window size (re-checked continuously; "
"a live resize adapts). While the game's mode task is the mission "
"(gameplay, in-engine cutscenes, pause), cameras are patched from "
"16:9 to the window aspect, the presenter fills the window, and "
"the 2D layer is pre-squeezed at draw time for a crisp 16:9 HUD "
"(the game-placed target-marker shader stays full-width by design; "
"its art is squared by narrowing the geometry). Everywhere else "
"(menus, hangar, briefing, FMV, attract) and at 16:9-or-narrower "
"windows, cameras stay 16:9 and the presentation letterboxes - "
"vanilla. While enabled the feature drives the presenter's "
"letterbox decision itself; it never writes the present_letterbox "
"cvar, so nothing leaks into a saved config.");
REXCVAR_DEFINE_BOOL(ac6_widescreen_cinematics, true, "AC6",
"With ac6_widescreen: render in-engine cinematics wide too, even "
"outside the mission mode task (detected via the demo-manager "
"cinematic signal, the same one the cutscene frame-rate clamp "
"uses - so ac6_cutscene_clamp=false disables this detection). "
"Cinematics are staged and framed for 16:9, so widening can "
"expose things outside that frame (set edges, actors popping in "
"at the frustum border). Off = cinematics letterbox at 16:9 "
"as staged.");
REXCVAR_DEFINE_BOOL(ac6_widescreen, false, "AC6/Enhancements",
"Ultrawide support (hor+), in missions only. The aspect follows "
"the window size; menus and the front end stay vanilla 16:9.");
REXCVAR_DEFINE_BOOL(ac6_widescreen_cinematics, true, "AC6/Enhancements",
"With ultrawide on, render in-engine cinematics wide too. They "
"are staged for 16:9, so widening can expose set edges.");
// Read (never written) only to report it in the activation log.
REXCVAR_DECLARE(bool, present_letterbox);
+11
View File
@@ -20,6 +20,11 @@
REXCVAR_DECLARE(std::string, ac6_graphics_backend);
// Defined in main.cpp: the config preset suite (session defaults + fix
// payload expansion) and its post-logging summary lines.
void ApplyAc6ConfigPresets();
void LogAc6ConfigPresetSummary();
class Ac6recompApp : public rex::ReXApp {
public:
using rex::ReXApp::ReXApp;
@@ -40,9 +45,15 @@ class Ac6recompApp : public rex::ReXApp {
// and rejects wrong/corrupt images with a clear message.
uint32_t OnGetExpectedTitleId() const override { return 0x4E4D07D1; }
// Runs right after the toml is parsed and before anything consumes cvars:
// the one place presets can both see the user's real values and still act
// before logging/window/graphics read the results.
void OnConfigLoaded() override { ApplyAc6ConfigPresets(); }
void OnPreSetup(rex::RuntimeConfig& config) override {
REXLOG_INFO("Ac6recompApp::OnPreSetup");
rex::ReXApp::OnPreSetup(config);
LogAc6ConfigPresetSummary();
const std::string requested_backend = REXCVAR_GET(ac6_graphics_backend);
#if REX_HAS_VULKAN
+130 -116
View File
@@ -12,37 +12,20 @@ REXCVAR_DECLARE(bool, ac6_native_graphics_enabled);
REXCVAR_DECLARE(bool, ac6_force_safe_draw_resolution_scale);
REXCVAR_DECLARE(bool, ac6_force_safe_direct_host_resolve);
REXCVAR_DECLARE(std::string, ac6_graphics_mode);
REXCVAR_DECLARE(bool, direct_host_resolve);
REXCVAR_DECLARE(int32_t, resolution_scale);
REXCVAR_DECLARE(int32_t, draw_resolution_scale_x);
REXCVAR_DECLARE(int32_t, draw_resolution_scale_y);
REXCVAR_DECLARE(bool, param_gen_integer_guest_position);
REXCVAR_DECLARE(bool, param_gen_host_subpixel_restore);
REXCVAR_DECLARE(std::string, ac6_neutralize_deswizzle_hashes);
REXCVAR_DECLARE(std::string, ac6_snap_guest_texel_hashes);
REXCVAR_DECLARE(std::string, ac6_densify_x_fetch_hashes);
REXCVAR_DECLARE(std::string, ac6_densify_y_fetch_hashes);
REXCVAR_DECLARE(std::string, log_file);
REXCVAR_DECLARE(bool, param_gen_integer_guest_position);
REXCVAR_DECLARE(bool, param_gen_host_subpixel_restore);
REXCVAR_DECLARE(std::string, log_level);
REXCVAR_DECLARE(bool, ac6_d3d_trace);
REXCVAR_DECLARE(bool, ac6_backend_debug_swap);
REXCVAR_DECLARE(bool, ac6_backend_log_signatures);
REXCVAR_DECLARE(bool, ac6_backend_signature_diagnostics);
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);
REXCVAR_DECLARE(bool, d3d12_low_latency_swap_chain);
REXCVAR_DECLARE(int32_t, d3d12_max_frame_latency);
#endif
REXCVAR_DECLARE(bool, vfetch_index_rounding_bias);
REXCVAR_DECLARE(int32_t, video_mode_width);
REXCVAR_DECLARE(int32_t, video_mode_height);
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"
@@ -61,25 +44,21 @@ REXCVAR_DEFINE_STRING(ac6_graphics_backend, AC6_DEFAULT_GRAPHICS_BACKEND,
REXCVAR_DEFINE_BOOL(ac6_performance_mode, true, "AC6/Performance",
"Disable all diagnostics, logging, and development overlays for maximum runtime performance");
// Correctness-fix switches. Each expands
// to its detailed cvars + AC6-specific shader hashes in ApplyAc6FixDefaults()
// unless the user overrode them.
REXCVAR_DEFINE_BOOL(ac6_fix_scaling, true, "AC6",
"Fix the upscaling mosaic on AC6's deferred EDRAM restore passes by "
"flooring the PsParamGen pixel position (+ host sub-pixel restore). "
"Auto-applies only at draw resolution scale > 1; inert at 1x. On by default.");
REXCVAR_DEFINE_BOOL(ac6_fix_deswizzle, true, "AC6",
"Fix the de-swizzle mosaic/streaks: identity-override AC6's manual EDRAM "
"sub-tile de-swizzle, which is always a wrong texel permutation once the "
"emulator detiles to linear. On by default; effective at all draw scales "
"(the swizzle is present at 1x too).");
REXCVAR_DEFINE_BOOL(ac6_fix_dof, true, "AC6",
"Fix the in-engine cutscene depth-of-field striping/ghosting at draw "
"resolution scale > 1: the 6-pass DoF chain is authored for the 640x360 "
"grid, so its guest-texel kernels alias against the extra detail of "
"scaled sources (combed CoC weights, ghost copies at the sparse gather "
"taps). Snaps the CoC pre-blurs to the guest grid and densifies the "
"gather taps along each pass's axis. On by default; inert at 1x.");
// Correctness-fix switches. Each expands to its detailed cvars + AC6-specific
// shader hashes in ApplyAc6FixDefaults() unless the user overrode them. The
// payloads apply at shader translation, so a change needs a restart.
REXCVAR_DEFINE_BOOL(ac6_fix_scaling, true, "AC6/Fixes",
"Fix the upscaling mosaic on deferred rendering passes. Only "
"active at resolution scale above 1x.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(ac6_fix_deswizzle, true, "AC6/Fixes",
"Fix mosaic and streak artifacts from the game's manual texture "
"de-swizzle. Effective at all resolution scales.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(ac6_fix_dof, true, "AC6/Fixes",
"Fix cutscene depth-of-field striping and ghosting at resolution "
"scale above 1x.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
#include "generated/ac6recomp_config.h"
#include "generated/ac6recomp_init.h"
@@ -105,63 +84,69 @@ void ApplyAc6HybridStartupSafetyOverrides() {
}
if (REXCVAR_GET(ac6_force_safe_draw_resolution_scale)) {
REXCVAR_SET(resolution_scale, 1);
REXCVAR_SET(draw_resolution_scale_x, 1);
REXCVAR_SET(draw_resolution_scale_y, 1);
rex::cvar::SetSessionDefault("resolution_scale", "1",
"ac6_force_safe_draw_resolution_scale");
rex::cvar::SetSessionDefault("draw_resolution_scale_x", "1",
"ac6_force_safe_draw_resolution_scale");
rex::cvar::SetSessionDefault("draw_resolution_scale_y", "1",
"ac6_force_safe_draw_resolution_scale");
}
// (The >1x param-gen mosaic fix is applied by ApplyAc6FixDefaults via
// ac6_fix_scaling.)
if (REXCVAR_GET(ac6_force_safe_direct_host_resolve)) {
REXCVAR_SET(direct_host_resolve, false);
rex::cvar::SetSessionDefault("direct_host_resolve", "false",
"ac6_force_safe_direct_host_resolve");
}
}
void ApplyAc6DefaultSettings() {
if (!rex::cvar::HasNonDefaultValue("vsync")) {
REXCVAR_SET(vsync, true);
}
if (!rex::cvar::HasNonDefaultValue("guest_vblank_sync_to_refresh")) {
REXCVAR_SET(guest_vblank_sync_to_refresh, true);
}
if (!rex::cvar::HasNonDefaultValue("host_present_from_non_ui_thread")) {
REXCVAR_SET(host_present_from_non_ui_thread, true);
}
rex::cvar::SetSessionDefault("vsync", "true");
rex::cvar::SetSessionDefault("guest_vblank_sync_to_refresh", "true");
rex::cvar::SetSessionDefault("host_present_from_non_ui_thread", "true");
#if REX_HAS_D3D12
if (!rex::cvar::HasNonDefaultValue("d3d12_allow_variable_refresh_rate_and_tearing")) {
REXCVAR_SET(d3d12_allow_variable_refresh_rate_and_tearing, true);
}
if (!rex::cvar::HasNonDefaultValue("d3d12_low_latency_swap_chain")) {
REXCVAR_SET(d3d12_low_latency_swap_chain, true);
}
if (!rex::cvar::HasNonDefaultValue("d3d12_max_frame_latency")) {
REXCVAR_SET(d3d12_max_frame_latency, 1);
}
rex::cvar::SetSessionDefault("d3d12_allow_variable_refresh_rate_and_tearing", "true");
rex::cvar::SetSessionDefault("d3d12_low_latency_swap_chain", "true");
rex::cvar::SetSessionDefault("d3d12_max_frame_latency", "1");
#endif
if (!rex::cvar::HasNonDefaultValue("vfetch_index_rounding_bias")) {
REXCVAR_SET(vfetch_index_rounding_bias, true);
}
if (!rex::cvar::HasNonDefaultValue("direct_host_resolve")) {
REXCVAR_SET(direct_host_resolve, false);
}
if (!rex::cvar::HasNonDefaultValue("video_mode_width")) {
REXCVAR_SET(video_mode_width, 1920);
}
if (!rex::cvar::HasNonDefaultValue("video_mode_height")) {
REXCVAR_SET(video_mode_height, 1080);
}
if (!rex::cvar::HasNonDefaultValue("resolution")) {
REXCVAR_SET(resolution, "1080p");
}
if (!rex::cvar::HasNonDefaultValue("window_width")) {
REXCVAR_SET(window_width, 1920);
}
if (!rex::cvar::HasNonDefaultValue("window_height")) {
REXCVAR_SET(window_height, 1080);
}
rex::cvar::SetSessionDefault("vfetch_index_rounding_bias", "true");
rex::cvar::SetSessionDefault("direct_host_resolve", "false");
rex::cvar::SetSessionDefault("video_mode_width", "1920");
rex::cvar::SetSessionDefault("video_mode_height", "1080");
rex::cvar::SetSessionDefault("resolution", "1080p");
rex::cvar::SetSessionDefault("window_width", "1920");
rex::cvar::SetSessionDefault("window_height", "1080");
}
void ApplyAc6PerformanceModeOverrides() {
if (REXCVAR_GET(ac6_performance_mode)) {
// Session-default RECORDING: gives the settings menu the "(set by
// ac6_performance_mode)" annotation and the boot-time value. The
// actual enforcement stays the pre-existing per-frame re-assert
// (EnforceAc6PerformanceModeOverrides below) - these cvars predate
// this rework and keep their upstream semantics: performance mode
// forces them off while enabled.
rex::cvar::SetSessionDefault("log_level", "error", "ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_d3d_trace", "false", "ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_render_capture", "false", "ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_backend_debug_swap", "false", "ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_backend_log_signatures", "false",
"ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_backend_signature_diagnostics", "false",
"ac6_performance_mode");
rex::cvar::SetSessionDefault("ac6_texture_swaps_dump_enabled", "false",
"ac6_performance_mode");
} else {
rex::cvar::SetSessionDefault("log_level", "debug");
}
}
// The pre-existing enforcement, unchanged from before this rework: while
// performance mode is on, the diagnostics are re-forced off every frame from
// the graphics overlay. These writes go through REXCVAR_SET (raw storage), so
// they no longer leak into a settings-menu save - the save writes the user's
// own values (FlagEntry::user_value).
void EnforceAc6PerformanceModeOverrides() {
if (!REXCVAR_GET(ac6_performance_mode)) {
return;
}
@@ -174,45 +159,86 @@ void ApplyAc6PerformanceModeOverrides() {
REXCVAR_SET(ac6_texture_swaps_dump_enabled, false);
}
// Each fix switch -> its detailed cvars + AC6-specific shader hashes. The
// game-specific constants live here in code, not in the user's toml; each
// value is filled in only if the user did NOT set it explicitly, so any toml
// override still wins (the dev/debug path).
#define AC6_SET_IF_UNSET(cv, value) \
do { if (!rex::cvar::HasNonDefaultValue(#cv)) REXCVAR_SET(cv, value); } while (0)
void ApplyAc6FixDefaults() {
// Post-config, these read the USER's draw scale - with the old app-create
// timing this gate always saw the default 1x, so the scaling fix payload
// never engaged for anyone who set their scale in the toml.
const bool scaled = REXCVAR_GET(draw_resolution_scale_x) > 1 ||
REXCVAR_GET(draw_resolution_scale_y) > 1;
// param_gen floor + host sub-pixel restore only bites at >1x --
// inert at 1x, so gate it on the draw scale.
if (REXCVAR_GET(ac6_fix_scaling) && scaled) {
AC6_SET_IF_UNSET(param_gen_integer_guest_position, true);
AC6_SET_IF_UNSET(param_gen_host_subpixel_restore, true);
rex::cvar::SetSessionDefault("param_gen_integer_guest_position", "true",
"ac6_fix_scaling");
rex::cvar::SetSessionDefault("param_gen_host_subpixel_restore", "true",
"ac6_fix_scaling");
}
// de-swizzle identity override is a texture-layout mismatch present
// at ALL draw scales, so it is NOT gated on scale.
if (REXCVAR_GET(ac6_fix_deswizzle)) {
AC6_SET_IF_UNSET(ac6_neutralize_deswizzle_hashes,
"7d22894002d16018, 17e5e4ac3e713245:4");
rex::cvar::SetSessionDefault("ac6_neutralize_deswizzle_hashes",
"7d22894002d16018, 17e5e4ac3e713245:4",
"ac6_fix_deswizzle");
}
// Cutscene DoF chain (CoC pre-blur pair snapped to the guest grid; the
// two 13-tap gathers densified along their pass axes). The translator
// only emits these at draw scale > 1, so no scale gate is needed here.
if (REXCVAR_GET(ac6_fix_dof)) {
AC6_SET_IF_UNSET(ac6_snap_guest_texel_hashes,
"85d733927e3bba9c, d050dfa6f58567f6");
AC6_SET_IF_UNSET(ac6_densify_x_fetch_hashes, "6328f9c40913c82c");
AC6_SET_IF_UNSET(ac6_densify_y_fetch_hashes, "5bd20f9d0d911687");
rex::cvar::SetSessionDefault("ac6_snap_guest_texel_hashes",
"85d733927e3bba9c, d050dfa6f58567f6", "ac6_fix_dof");
rex::cvar::SetSessionDefault("ac6_densify_x_fetch_hashes", "6328f9c40913c82c",
"ac6_fix_dof");
rex::cvar::SetSessionDefault("ac6_densify_y_fetch_hashes", "5bd20f9d0d911687",
"ac6_fix_dof");
}
}
#undef AC6_SET_IF_UNSET
} // namespace
void ApplyAc6PerformanceModeOverridesPublic() {
// The whole preset suite. Called from Ac6recompApp::OnConfigLoaded - AFTER
// rex::cvar::LoadConfig parsed the user's toml (so the master switches and
// gates read the user's real values, fixing the "ac6_fix_* dead from the
// toml" bug) and BEFORE anything consumes cvars (logging, window, graphics).
// Everything goes through SetSessionDefault: a user-set value always wins,
// and none of these values leak into the user's config on a settings save.
// The game-specific fix constants stay here in code, not in the user's toml.
void ApplyAc6ConfigPresets() {
rex::cvar::SetSessionDefault("log_file", "ac6recomp.log");
ApplyAc6DefaultSettings();
ApplyAc6HybridStartupSafetyOverrides();
ApplyAc6FixDefaults();
ApplyAc6PerformanceModeOverrides();
// Runtime enforcement of performance mode stays the pre-existing
// per-frame re-assert from the graphics overlay (see
// ApplyAc6PerformanceModeOverridesPublic).
}
void ApplyAc6PerformanceModeOverridesPublic() {
EnforceAc6PerformanceModeOverrides();
}
// One greppable summary of what the presets decided, logged from OnPreSetup
// (the presets themselves run before logging is initialized). Logged at
// error level so it survives performance mode's log_level=error session
// default - the same reason the widescreen activation line logs at error.
// This is the support line: it shows whether a master switch genuinely
// disengaged its payload.
void LogAc6ConfigPresetSummary() {
REXLOG_ERROR("AC6 config: graphics mode={} capture={}", REXCVAR_GET(ac6_graphics_mode),
REXCVAR_GET(ac6_render_capture) ? "true" : "false");
REXLOG_ERROR("AC6 fixes: scaling={} deswizzle={} dof={} perf_mode={} unlock_fps={}",
REXCVAR_GET(ac6_fix_scaling), REXCVAR_GET(ac6_fix_deswizzle),
REXCVAR_GET(ac6_fix_dof), REXCVAR_GET(ac6_performance_mode),
REXCVAR_GET(ac6_unlock_fps));
REXLOG_ERROR("AC6 fix payloads: neutralize='{}' snap='{}' densify_x='{}' densify_y='{}' "
"param_gen={}/{}",
REXCVAR_GET(ac6_neutralize_deswizzle_hashes),
REXCVAR_GET(ac6_snap_guest_texel_hashes),
REXCVAR_GET(ac6_densify_x_fetch_hashes),
REXCVAR_GET(ac6_densify_y_fetch_hashes),
REXCVAR_GET(param_gen_integer_guest_position),
REXCVAR_GET(param_gen_host_subpixel_restore));
}
void InitEarlyLog() {
@@ -230,24 +256,12 @@ std::unique_ptr<rex::ui::WindowedApp> Ac6recompAppCreate(rex::ui::WindowedAppCon
g_boot_log << "Ac6recompApp::Create called" << std::endl;
g_boot_log.flush();
}
// Force SDK logging to a file as well
REXCVAR_SET(log_file, "ac6recomp.log");
if (!rex::cvar::HasNonDefaultValue("log_level")) {
REXCVAR_SET(log_level, "debug");
}
// 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();
ApplyAc6PerformanceModeOverrides();
REXLOG_INFO("Ac6recompAppCreate: graphics mode={} capture={}",
REXCVAR_GET(ac6_graphics_mode),
REXCVAR_GET(ac6_render_capture) ? "true" : "false");
// No cvar presets here: this runs BEFORE rex::cvar::LoadConfig parses
// the toml, so anything applied here reads defaults, not user values.
// The preset suite lives in ApplyAc6ConfigPresets, called from
// Ac6recompApp::OnConfigLoaded.
return Ac6recompApp::Create(ctx);
}
+16 -35
View File
@@ -28,41 +28,22 @@ REXCVAR_DEFINE_BOOL(ac6_unlock_fps, 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.");
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.");
REXCVAR_DEFINE_BOOL(ac6_dynamic_vblank, true, "AC6/Enhancements",
"Pace menus and cutscenes at the native 60Hz vblank while "
"gameplay runs at the target rate.");
REXCVAR_DEFINE_DOUBLE(ac6_fps_target, 60.0, "AC6/Enhancements",
"Target framerate under the FPS unlock. 0 = auto: the highest "
"rate up to ac6_max_sim_fps that evenly divides your monitor's "
"refresh rate.");
REXCVAR_DEFINE_DOUBLE(ac6_max_sim_fps, 60.0, "AC6/Enhancements",
"Ceiling on the simulation rate. The game's physics is only "
"validated to 60fps; raise only for testing.");
REXCVAR_DEFINE_BOOL(ac6_dt_snap, true, "AC6/Enhancements",
"Snap the simulation step to the pacing target when within ~1ms "
"of it, removing residual micro-stutter and shake.");
REXCVAR_DEFINE_DOUBLE(ac6_min_sim_fps, 20.0, "AC6/Enhancements",
"Lowest framerate the game runs at true speed before slow "
"motion kicks in. Stock behaviour is 30.");
using Clock = std::chrono::steady_clock;
+45
View File
@@ -146,6 +146,25 @@ struct FlagEntry {
Constraints constraints;
std::string default_value;
bool is_debug_only = false;
// Set when the value came from the user - config file, console, or settings
// UI (SetFlagByName). Serialization persists exactly these entries: a saved
// config contains what the user chose, nothing else. Code writes via
// REXCVAR_SET and per-launch command-line/environment overrides do not mark
// this and are never persisted.
bool user_set = false;
// The last value the user set (canonical getter form), captured by
// SetFlagByName. Serialization writes THIS, not the live value: a feature
// that forces the live value (e.g. performance mode forcing a diagnostic
// off) must not corrupt what the user chose in their saved config.
std::string user_value;
// Session default (SetSessionDefault): the value the app wants this session
// when the user has not chosen one. Applied to the storage only when the
// user did not set the flag; never serialized. session_driver optionally
// names the cvar that drove it (e.g. a feature master switch) so settings
// UIs can show the relationship.
bool has_session_default = false;
std::string session_default;
std::string session_driver;
};
std::vector<FlagEntry>& GetRegistry();
@@ -198,6 +217,32 @@ void ResetToDefault(std::string_view name);
void ResetAllToDefaults();
bool HasNonDefaultValue(std::string_view name);
std::vector<std::string> ListModifiedFlags();
/**
* Apply a project/session default: the value a preset or feature wants this
* session when the user has not explicitly chosen one. Unlike REXCVAR_SET,
* this never clobbers a user-set value (config file, console, settings UI)
* and the value is never serialized into a saved config. `driver` optionally
* records the cvar that drove the value (a feature master switch such as
* ac6_fix_deswizzle) for display in settings UIs.
* Returns true if the value was applied to the flag, false if it was only
* recorded (a user-set value wins) or rejected (unknown flag, command flag,
* failed constraints).
*/
bool SetSessionDefault(std::string_view name, std::string_view value,
std::string_view driver = {});
/// Whether the flag was explicitly set by the user this session (config file,
/// console, settings UI). Command-line and environment overrides do not
/// count: they are per-launch and are not persisted by SaveConfig.
bool IsUserSet(std::string_view name);
/**
* Serialize user-set flags (see FlagEntry::user_set) as TOML lines. This is
* what SaveConfig writes: exactly the settings the user chose - loaded from
* their config file or changed in the console/settings UI - and none of the
* values that presets or feature code applied.
*/
std::string SerializeToTOML();
std::string SerializeToTOML(std::string_view category);
+9
View File
@@ -83,6 +83,15 @@ class ReXApp : public ui::WindowedApp, public ui::WindowListener, public ui::Win
// --- Virtual hooks for customization ---
/// Called right after the config file (cvars) was loaded - or would have
/// been, when none exists - and before ANYTHING consumes cvar values
/// (logging setup, game source resolution, runtime/graphics setup, window
/// creation all come later). This is the place for app-level config
/// presets and session defaults (rex::cvar::SetSessionDefault): here they
/// see the user's real config values, unlike the app-create path, which
/// runs before the config file is parsed.
virtual void OnConfigLoaded() {}
/// Called before Runtime::Setup(). Override to modify backend config.
virtual void OnPreSetup(RuntimeConfig& config) {}
@@ -30,6 +30,9 @@ class SettingsDialog : public ImGuiDialog {
private:
bool visible_ = false;
// Show debug-only cvars (diagnostics, investigation switches). Off by
// default so players see the curated surface; toggled in the bottom bar.
bool show_developer_ = false;
std::filesystem::path config_path_;
char search_buf_[128] = {};
std::string selected_category_;
+158 -28
View File
@@ -9,6 +9,7 @@
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
@@ -162,6 +163,60 @@ bool ValidateConstraints(const FlagEntry& entry, std::string_view value) {
return true;
}
// Caller holds the registry mutex (recursive, so callbacks may re-enter).
void InvokeChangeCallbacksLocked(std::string_view name, std::string_view value) {
auto& callbacks = GetCallbackStorage();
auto it = callbacks.find(std::string(name));
if (it != callbacks.end()) {
for (const auto& callback : it->second) {
callback(name, value);
}
}
}
// TOML basic-string escaping for serialized values. Without this a saved
// Windows path ("C:\Games\ac6.iso") produces an invalid escape sequence,
// the next LoadConfig hits a parse error, and the WHOLE config silently
// reverts to defaults.
std::string EscapeTomlBasicString(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
switch (c) {
case '\\':
out += "\\\\";
break;
case '"':
out += "\\\"";
break;
case '\n':
out += "\\n";
break;
case '\r':
out += "\\r";
break;
case '\t':
out += "\\t";
break;
case '\b':
out += "\\b";
break;
case '\f':
out += "\\f";
break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04X", static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
return out;
}
} // namespace
//=============================================================================
@@ -231,7 +286,7 @@ bool SetFlagByName(std::string_view name, std::string_view value) {
return false;
}
const auto& entry = GetRegistryStorage()[it->second];
auto& entry = GetRegistryStorage()[it->second];
// Check lifecycle
if (!g_lifecycle_override && entry.lifecycle == Lifecycle::kInitOnly && IsFinalized()) {
@@ -246,25 +301,70 @@ bool SetFlagByName(std::string_view name, std::string_view value) {
bool success = entry.setter(value);
// Track pending restart flags
if (success && entry.lifecycle == Lifecycle::kRequiresRestart) {
MarkPendingRestart(name);
}
// Invoke registered callbacks
if (success) {
auto& callbacks = GetCallbackStorage();
auto it = callbacks.find(std::string(name));
if (it != callbacks.end()) {
for (const auto& callback : it->second) {
callback(name, value);
}
// Every SetFlagByName caller is a user-intent path (config file load,
// console, settings UI), so the flag is now user-owned: SaveConfig
// persists it, session defaults no longer apply to it. The canonical
// value is captured so later code writes to the live value (REXCVAR_SET
// enforcement) cannot corrupt what gets saved.
entry.user_set = true;
entry.user_value = entry.getter();
// Track pending restart flags
if (entry.lifecycle == Lifecycle::kRequiresRestart) {
MarkPendingRestart(name);
}
InvokeChangeCallbacksLocked(name, value);
}
return success;
}
bool SetSessionDefault(std::string_view name, std::string_view value, std::string_view driver) {
std::lock_guard lock(GetRegistryMutex());
auto it = GetRegistryIndex().find(std::string(name));
if (it == GetRegistryIndex().end()) {
REXLOG_WARN("SetSessionDefault: unknown flag '{}'", name);
return false;
}
auto& entry = GetRegistryStorage()[it->second];
if (entry.type == FlagType::Command) {
return false;
}
if (!ValidateConstraints(entry, value)) {
return false;
}
entry.has_session_default = true;
entry.session_default = std::string(value);
entry.session_driver = std::string(driver);
// A user-set value always wins: record the session default (for UI display)
// but leave the user's value in place.
if (entry.user_set) {
return false;
}
bool success = entry.setter(value);
if (success) {
// Deliberately no MarkPendingRestart: this is the value the app starts
// from, not a change the user needs to restart for.
InvokeChangeCallbacksLocked(name, value);
}
return success;
}
bool IsUserSet(std::string_view name) {
std::lock_guard lock(GetRegistryMutex());
auto it = GetRegistryIndex().find(std::string(name));
if (it == GetRegistryIndex().end()) {
return false;
}
return GetRegistryStorage()[it->second].user_set;
}
std::string GetFlagByName(std::string_view name) {
std::lock_guard lock(GetRegistryMutex());
auto it = GetRegistryIndex().find(std::string(name));
@@ -387,8 +487,17 @@ void ResetToDefault(std::string_view name) {
if (it == GetRegistryIndex().end()) {
return;
}
const auto& entry = GetRegistryStorage()[it->second];
entry.setter(entry.default_value);
auto& entry = GetRegistryStorage()[it->second];
// Reset means "back to stock, forget my choice": the effective default is
// the session default when one is in force, and the flag stops being
// user-owned so the next SaveConfig drops it.
const std::string& target =
entry.has_session_default ? entry.session_default : entry.default_value;
if (entry.setter(target)) {
entry.user_set = false;
entry.user_value.clear();
InvokeChangeCallbacksLocked(name, target);
}
}
void ResetAllToDefaults() {
@@ -419,16 +528,31 @@ std::vector<std::string> ListModifiedFlags() {
return result;
}
namespace {
void AppendTomlLine(std::string& result, const FlagEntry& entry) {
// user_value, not getter(): the live value may be feature-forced (e.g.
// performance mode holding a diagnostic off); the save keeps the user's
// own choice.
if (entry.type == FlagType::String) {
result += entry.name + " = \"" + EscapeTomlBasicString(entry.user_value) + "\"\n";
} else {
result += entry.name + " = " + entry.user_value + "\n";
}
}
} // namespace
std::string SerializeToTOML() {
std::lock_guard lock(GetRegistryMutex());
std::string result;
for (const auto& entry : GetRegistryStorage()) {
if (entry.getter() != entry.default_value) {
if (entry.type == FlagType::String) {
result += entry.name + " = \"" + entry.getter() + "\"\n";
} else {
result += entry.name + " = " + entry.getter() + "\n";
}
// Persist exactly what the user chose. Values written by presets or
// feature code (REXCVAR_SET / SetSessionDefault) are session state and
// must not outlive the code that applied them by leaking into the
// user's config.
if (entry.user_set) {
AppendTomlLine(result, entry);
}
}
return result;
@@ -438,12 +562,8 @@ std::string SerializeToTOML(std::string_view category) {
std::lock_guard lock(GetRegistryMutex());
std::string result;
for (const auto& entry : GetRegistryStorage()) {
if (entry.category == category && entry.getter() != entry.default_value) {
if (entry.type == FlagType::String) {
result += entry.name + " = \"" + entry.getter() + "\"\n";
} else {
result += entry.name + " = " + entry.getter() + "\n";
}
if (entry.category == category && entry.user_set) {
AppendTomlLine(result, entry);
}
}
return result;
@@ -568,7 +688,17 @@ ScopedLifecycleOverride::~ScopedLifecycleOverride() {
}
void ResetAllForTesting() {
ResetAllToDefaults();
{
std::lock_guard lock(GetRegistryMutex());
for (auto& entry : GetRegistryStorage()) {
entry.setter(entry.default_value);
entry.user_set = false;
entry.user_value.clear();
entry.has_session_default = false;
entry.session_default.clear();
entry.session_driver.clear();
}
}
ClearPendingRestartFlags();
g_finalized = false;
}
+53 -71
View File
@@ -33,81 +33,63 @@ REXCVAR_DEFINE_BOOL(vfetch_index_rounding_bias, false, "GPU/Shader",
"flooring to fix black triangles caused by RCP precision");
REXCVAR_DEFINE_BOOL(draw_resolution_scaled_texture_offsets, true, "GPU/Shader",
"Scale texture offsets with draw resolution");
REXCVAR_DEFINE_BOOL(param_gen_integer_guest_position, false, "GPU/Shader",
"At >1x draw resolution scale, floor the PsParamGen pixel "
"position to the integer guest-pixel index instead of keeping "
"the sub-guest-pixel fraction. Fixes the mosaic in games that "
"feed the position into their own integer pixel-address math "
"(e.g. AC6's deferred EDRAM restore/de-swizzle passes), whose "
"frac()-based bit extraction otherwise sees a doubled period and "
"scrambles the sample coordinate. Those passes then sample at "
"guest resolution. Off by default; harmless for shaders that pass "
"the position straight to tfetch only at 1x.");
REXCVAR_DEFINE_BOOL(param_gen_host_subpixel_restore, false, "GPU/Shader",
"Builds on param_gen_integer_guest_position (and implies it): for "
"resolution-scaled, position-derived 2D samples in pixel shaders "
"that use PsParamGen, re-adds the host sub-pixel offset to the "
"sample coordinate so the de-swizzle restore passes sample at full "
"host resolution instead of the guest-texel center. Turns the "
"mosaic fix from clean-but-soft into true resolution-scaled detail. "
"Off by default; may need per-shader scoping if it disturbs other "
"passes that read scaled render targets via interpolated coords.");
REXCVAR_DEFINE_STRING(ac6_neutralize_deswizzle_hashes, "", "GPU/Shader",
"AC6: comma/space-separated tokens \"<hash>[:<slot>[+<slot>...]]\" "
"naming guest pixel-shader ucode hashes (hex) whose param_gen 2D "
"texture samples manually de-swizzle the raw EDRAM sub-tile order of "
"their source. The emulator's texture cache detiles everything to "
"linear, so that de-swizzle is always a wrong texel permutation here "
"(the mosaic/streak class). For a matching fetch the sample "
"coordinate is replaced unconditionally with the identity host-texel "
"UV (SV_Position.xy / (guest_size * scale)) -- a 1:1 copy. A bare "
"hash overrides ALL of that shader's fetches; \":4\" limits it to "
"Xenos tfetch slot 4 (needed when only some fetches de-swizzle, e.g. "
"the AC6 cloud compositor: scene fetch de-swizzles, mask/cloud "
"fetches use plain UVs and must be left alone). Runtime, no rebuild.");
REXCVAR_DEFINE_STRING(ac6_snap_guest_texel_hashes, "", "GPU/Shader",
"AC6: same \"<hash>[:<slot>[+<slot>...]]\" token list as "
"ac6_neutralize_deswizzle_hashes, naming guest pixel-shader ucode "
"hashes whose texture taps encode fractional guest-texel bilinear "
"positions (e.g. the cutscene depth-of-field gather pair). Sampling "
"a resolution-scaled texture with such a kernel reinterprets every "
"blend ratio against the finer host grid and alternates the kernel "
"phase per output pixel, aliasing into striping at >1x. For matching "
"fetches the normalized coordinate is snapped to the guest texel "
"center (where host bilinear of a scaled texture returns exactly the "
"guest texel's box average), restoring the guest sampling grid at any "
"draw resolution scale. Applied only at >1x and only to "
"resolution-scaled textures. Runtime, no rebuild.");
REXCVAR_DEFINE_STRING(ac6_densify_x_fetch_hashes, "", "GPU/Shader",
"AC6: \"<hash>[:<slot>[+<slot>...]]\" list for HORIZONTAL-axis "
"separable filter passes (e.g. the cutscene DoF H gather "
"6328f9c40913c82c). Each allowlisted fetch becomes the average of "
"2*draw_resolution_scale_x samples spread along X across one "
"guest-texel half-gap on each side of the original tap - the union "
"of all taps' cells covers the kernel span continuously, so ghost "
"copies of arbitrarily thin features merge at any resolution scale. "
"Convolving the authored kernel with the 2-texel cell box widens the "
"blur by ~2% (visually shape-faithful). Per-axis scale aware; no-op "
"when draw_resolution_scale_x is 1. Read at shader translation.");
REXCVAR_DEFINE_STRING(ac6_densify_y_fetch_hashes, "", "GPU/Shader",
"AC6: as ac6_densify_x_fetch_hashes, for VERTICAL-axis separable "
"filter passes (e.g. the cutscene DoF V gather 5bd20f9d0d911687): "
"2*draw_resolution_scale_y samples along Y per fetch. No-op when "
"draw_resolution_scale_y is 1.");
REXCVAR_DEFINE_BOOL(ac6_flare_drop_quad2, true, "GPU/Shader",
"AC6: cull the sun lens-flare's spurious second billboard "
"(vertices 4-7) to remove the faint rectangle in the sky");
REXCVAR_DEFINE_INT32(ac6_flare_drop_index_min, 4, "GPU/Shader",
"AC6: first vertex index of the flare draw to cull (the "
"spurious billboard is vertices 4-7 of 8)");
// The param_gen pair and the ac6_* hash lists below are consumed at shader
// translation. Translated shaders persist in the shader storage, but storage
// stores ucode and re-translates at load, so a change applies at the next
// launch: kRequiresRestart.
REXCVAR_DEFINE_BOOL(param_gen_integer_guest_position, false, "AC6/Fixes",
"At above-1x draw resolution scale, floor the PsParamGen pixel "
"position to the integer guest-pixel index. Fixes mosaic in "
"passes doing their own integer pixel-address math.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_BOOL(param_gen_host_subpixel_restore, false, "AC6/Fixes",
"With param_gen_integer_guest_position: re-add the host "
"sub-pixel offset so restore passes keep full scaled detail.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_STRING(ac6_neutralize_deswizzle_hashes, "", "AC6/Fixes",
"Pixel-shader token list \"<hash>[:<slot>[+<slot>...]]\": replace "
"matching fetches' manual EDRAM de-swizzle with the identity "
"host-texel UV. Normally driven by ac6_fix_deswizzle.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_STRING(ac6_snap_guest_texel_hashes, "", "AC6/Fixes",
"Pixel-shader token list \"<hash>[:<slot>[+<slot>...]]\": snap "
"matching fetches to guest texel centers at above-1x scale. "
"Normally driven by ac6_fix_dof.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_STRING(ac6_densify_x_fetch_hashes, "", "AC6/Fixes",
"Token list: densify matching horizontal separable-filter "
"fetches at above-1x scale so ghost copies merge. Normally "
"driven by ac6_fix_dof.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_STRING(ac6_densify_y_fetch_hashes, "", "AC6/Fixes",
"As ac6_densify_x_fetch_hashes, for vertical-axis passes. "
"Normally driven by ac6_fix_dof.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_BOOL(ac6_flare_drop_quad2, true, "AC6/Fixes",
"Fix the faint rectangle around the sun by culling the lens "
"flare's spurious second billboard.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_INT32(ac6_flare_drop_index_min, 4, "AC6/Fixes",
"First vertex index of the flare draw to cull (the spurious "
"billboard is vertices 4-7 of 8).")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart)
.debug_only();
REXCVAR_DEFINE_BOOL(gpu_debug_markers, false, "GPU",
"Insert debug markers into GPU command streams for tools "
"like PIX and RenderDoc. Automatically enabled when "
"RenderDoc is detected.");
REXCVAR_DEFINE_BOOL(ac6_fix_trails, true, "AC6",
"Fix invisible missile/jet trails: drop stale cached vertex-buffer "
"residency so the GPU copy of AC6's fixed-address trail history ring "
"refreshes when the CPU writes it. On by default.");
// Consumed once at graphics SetupContext (a one-shot write-watch callback
// registration), so a change needs a restart to take effect.
REXCVAR_DEFINE_BOOL(ac6_fix_trails, true, "AC6/Fixes",
"Fix invisible missile and jet trails by refreshing the GPU copy "
"of the trail history when the CPU rewrites it.")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
bool IsGpuDebugMarkersEnabled() {
static bool cached = false;
+18 -47
View File
@@ -19,53 +19,24 @@ REXCVAR_DEFINE_BOOL(audio_trace_render_driver_verbose, false, "Audio",
"Trace render-driver activity");
REXCVAR_DEFINE_BOOL(audio_deep_trace, false, "Audio",
"Enable verbose runtime audio tracing");
REXCVAR_DEFINE_BOOL(audio_xma_loop_guard, true, "Audio",
"Require a real loop window (loop_start < loop_end, "
"read_offset >= loop_end) before engaging XMA loop "
"handling, as in current Xenia master (PR #1808, debugged "
"on Ace Combat 6). Off: legacy behavior, where streamed "
"voices carrying degenerate loop metadata (loop_count="
"0xff, loop_start == loop_end == 0) re-fire the loop "
"machinery on every input buffer swap, truncating/"
"skipping subframes of the stream (confirmed firing on "
"AC6 cutscene streams via the deep-trace counter).");
REXCVAR_DEFINE_BOOL(audio_xma_header_straddle_fix, true, "Audio",
"Decode XMA frames whose 15-bit frame header straddles a "
"2 KiB packet boundary. The frame walk treated "
"fewer-than-15 remaining bits as end-of-chain, but the "
"straddled frame is real: the encoder's own seek ledger "
"confirms it on every occurrence (its size field, read "
"across the boundary, equals the next packet's "
"first-frame offset minus the packet header). Without "
"this, one whole 512-sample frame is silently dropped per "
"occurrence, so the parallel streams of a multi-stream "
"voice drift apart in 512-sample steps and the premix "
"combs on the stereo fold - the long-reported doubled/"
"robotic cutscene dialogue. Off restores the legacy "
"walk for A/B.");
REXCVAR_DEFINE_BOOL(audio_xma_loop_end_guard, true, "Audio",
"Apply the degenerate-loop guard (see "
"audio_xma_loop_guard) to the loop-end frame test as "
"well. That test is evaluated before the loop-status "
"update, so the guard there never reaches it: with "
"loop_count > 0 and loop_start >= loop_end (metadata "
"AC6's streamed voices carry), the clamped loop_end "
"equals a voice's starting read offset for the stream "
"that begins at packet 0, arming the frame output limit "
"and silently discarding 384 samples of that stream's "
"first frame - permanently offsetting one context of a "
"multi-stream voice from its siblings. Off restores the "
"legacy behavior for A/B.");
REXCVAR_DEFINE_BOOL(audio_xma_preserve_timeline, true, "Audio",
"When the XMA decoder backend rejects a damaged frame, emit that "
"frame's worth of silence instead of dropping it. The input read "
"offset advances past the frame either way, so dropping the output "
"silently shortens the stream's timeline by 512 samples per damaged "
"frame - which would permanently desynchronize multi-stream sources "
"(5.1 premixes carried as parallel stereo streams, e.g. AC6's "
"cutscene mixes). Robustness fix - no in-game trigger is known in "
"AC6 (instrumented runs decode every frame). On by default; off "
"restores the legacy drop behavior.");
// The four audio_xma_* guards below are correctness fixes debugged on Ace
// Combat 6 (details in the commits introducing each). All default on; off
// restores the legacy behaviour for A/B comparison.
REXCVAR_DEFINE_BOOL(audio_xma_loop_guard, true, "AC6/Fixes",
"Guard XMA looping against degenerate loop metadata that "
"truncates streamed voices. Off = legacy behaviour for A/B.");
REXCVAR_DEFINE_BOOL(audio_xma_header_straddle_fix, true, "AC6/Fixes",
"Decode XMA frames whose header straddles a packet boundary. "
"Off = legacy walk, which drops a frame per occurrence and "
"combs multi-stream cutscene dialogue.");
REXCVAR_DEFINE_BOOL(audio_xma_loop_end_guard, true, "AC6/Fixes",
"Apply the degenerate-loop guard to the loop-end frame test "
"too, keeping multi-stream voices aligned from their first "
"frame. Off = legacy behaviour for A/B.");
REXCVAR_DEFINE_BOOL(audio_xma_preserve_timeline, true, "AC6/Fixes",
"Emit silence for XMA frames the decoder rejects instead of "
"dropping them, preserving multi-stream timing. Off = legacy "
"drop behaviour.");
namespace rex::audio {
+4
View File
@@ -379,6 +379,10 @@ bool ReXApp::OnInitialize() {
rex::cvar::LoadConfig(config_path);
}
// App-level config presets / session defaults: cvars hold their final
// user-config values and nothing has consumed them yet.
OnConfigLoaded();
// Logging setup from CVARs
std::string log_file_cvar = REXCVAR_GET(log_file);
std::string log_level_str = REXCVAR_GET(log_level);
+52 -8
View File
@@ -164,10 +164,21 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
auto& registry = rex::cvar::GetRegistry();
// Collect sorted unique category paths.
// Debug-only cvars (diagnostics, one-shot investigation switches) are
// hidden unless the developer toggle is on; they stay settable from the
// config file and the console.
auto entry_visible = [&](const rex::cvar::FlagEntry& entry) -> bool {
return show_developer_ || !entry.is_debug_only;
};
// Collect sorted unique category paths (of visible entries only, so a
// category that holds nothing but developer cvars does not show an empty
// page).
std::set<std::string> category_set;
for (auto& entry : registry) {
category_set.insert(entry.category);
if (entry_visible(entry)) {
category_set.insert(entry.category);
}
}
// Build tree: for each category path like "Input/Keybinds/Controller",
@@ -296,6 +307,9 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
ImGui::BeginChild("##cvars", ImVec2(0, -30.0f), false);
for (auto& entry : registry) {
if (!entry_visible(entry)) {
continue;
}
// Filter by category (unless searching).
if (!searching) {
if (!category_matches(entry.category)) {
@@ -386,7 +400,10 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
}
ImGui::SameLine();
if (ImGui::SmallButton("Reset##v")) {
rex::cvar::SetFlagByName(entry.name, entry.default_value);
// ResetToDefault, not SetFlagByName: reset means "back to stock,
// forget my choice" - the flag stops being user-owned and the next
// save drops it from the config.
rex::cvar::ResetToDefault(entry.name);
}
}
@@ -419,7 +436,7 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
} else {
// Non-keybind CVARs: colored label on left, value widget on right
ImGui::TextColored(LifecycleColor(entry.lifecycle), "%-20s", entry.name.c_str());
if (ImGui::IsItemHovered()) {
if (ImGui::IsItemHovered() && ImGui::BeginTooltip()) {
const char* lifecycle_label = "";
switch (entry.lifecycle) {
case rex::cvar::Lifecycle::kHotReload:
@@ -432,11 +449,24 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
lifecycle_label = "Read-only - set at initialization only";
break;
}
// Wrap: without a wrap position a long description renders as one
// screen-wide line.
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 28.0f);
if (!entry.description.empty()) {
ImGui::SetTooltip("%s\n[%s]", entry.description.c_str(), lifecycle_label);
} else {
ImGui::SetTooltip("[%s]", lifecycle_label);
ImGui::TextUnformatted(entry.description.c_str());
}
ImGui::TextDisabled("[%s]", lifecycle_label);
if (entry.has_session_default && !entry.session_driver.empty()) {
if (entry.user_set) {
ImGui::TextDisabled("Default '%s' comes from %s; your setting overrides it.",
entry.session_default.c_str(), entry.session_driver.c_str());
} else {
ImGui::TextDisabled("Set to '%s' by %s.", entry.session_default.c_str(),
entry.session_driver.c_str());
}
}
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
ImGui::SameLine(240.0f);
@@ -497,6 +527,18 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
rex::cvar::SetFlagByName(entry.name, buf);
}
}
// A cvar driven by another cvar (fix master switch, performance mode)
// says so inline, so a user editing it can tell where the value came
// from and what wins.
if (entry.has_session_default && !entry.session_driver.empty()) {
ImGui::SameLine();
if (entry.user_set) {
ImGui::TextDisabled("(overrides %s)", entry.session_driver.c_str());
} else {
ImGui::TextDisabled("(set by %s)", entry.session_driver.c_str());
}
}
}
if (read_only)
@@ -506,13 +548,15 @@ void SettingsDialog::OnDraw(ImGuiIO& /*io*/) {
}
ImGui::EndChild();
// Bottom bar: Save button.
// Bottom bar: Save button + developer toggle.
ImGui::Separator();
if (ImGui::Button("Save to config")) {
rex::cvar::SaveConfig(config_path_);
}
ImGui::SameLine();
ImGui::TextDisabled("(%s)", config_path_.filename().string().c_str());
ImGui::SameLine();
ImGui::Checkbox("Show developer settings", &show_developer_);
ImGui::End();
}
+155 -12
View File
@@ -276,6 +276,11 @@ TEST_CASE("cvar ListFlagsByLifecycle", "[cvar]") {
}
TEST_CASE("cvar reset and diff utilities", "[cvar]") {
// ResetToDefault honors a session default when one is in force; start from
// a clean slate so a session default left by another test case (random
// execution order) cannot change what "default" means here.
rex::cvar::testing::ResetAllForTesting();
SECTION("HasNonDefaultValue detects changes") {
REXCVAR_SET(test_int32_flag, 42); // Reset to default
CHECK_FALSE(rex::cvar::HasNonDefaultValue("test_int32_flag"));
@@ -316,20 +321,157 @@ TEST_CASE("cvar testing utilities", "[cvar]") {
}
}
TEST_CASE("cvar TOML serialization", "[cvar]") {
TEST_CASE("cvar TOML serialization persists user-set flags only", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
REXCVAR_SET(test_int32_flag, 999);
REXCVAR_SET(test_string_flag, "custom");
// User-intent path (config load / console / settings UI): persisted.
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "999"));
REQUIRE(rex::cvar::SetFlagByName("test_string_flag", "custom"));
// Code write (REXCVAR_SET): session state, never persisted.
REXCVAR_SET(test_bool_flag, true);
auto toml = rex::cvar::SerializeToTOML();
// Should contain modified flags
CHECK(toml.find("test_int32_flag = 999") != std::string::npos);
CHECK(toml.find("test_string_flag = \"custom\"") != std::string::npos);
// Should not contain flags at default
CHECK(toml.find("test_bool_flag") == std::string::npos);
SECTION("a user-set flag persists even at its default value") {
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "42")); // == default
auto toml2 = rex::cvar::SerializeToTOML();
CHECK(toml2.find("test_int32_flag = 42") != std::string::npos);
}
}
TEST_CASE("cvar user-set tracking", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
SECTION("IsUserSet reflects how the value was set") {
CHECK_FALSE(rex::cvar::IsUserSet("test_int32_flag"));
REXCVAR_SET(test_int32_flag, 7);
CHECK_FALSE(rex::cvar::IsUserSet("test_int32_flag"));
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "8"));
CHECK(rex::cvar::IsUserSet("test_int32_flag"));
}
SECTION("LoadConfig marks flags user-set; save round-trips exactly the file") {
auto config_path = std::filesystem::temp_directory_path() / "test_user_set.toml";
{
std::ofstream file(config_path);
file << "test_int32_flag = 500\n";
file << "test_bool_flag = true\n";
}
rex::cvar::LoadConfig(config_path);
std::filesystem::remove(config_path);
CHECK(rex::cvar::IsUserSet("test_int32_flag"));
CHECK(rex::cvar::IsUserSet("test_bool_flag"));
auto toml = rex::cvar::SerializeToTOML();
CHECK(toml == "test_bool_flag = true\ntest_int32_flag = 500\n");
}
SECTION("failed sets do not mark user-set") {
CHECK_FALSE(rex::cvar::SetFlagByName("test_ranged_flag", "100"));
CHECK_FALSE(rex::cvar::IsUserSet("test_ranged_flag"));
}
}
TEST_CASE("cvar session defaults", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
SECTION("applies when the user has not set the flag") {
REQUIRE(rex::cvar::SetSessionDefault("test_int32_flag", "77"));
CHECK(REXCVAR_GET(test_int32_flag) == 77);
CHECK_FALSE(rex::cvar::IsUserSet("test_int32_flag"));
// Never serialized.
CHECK(rex::cvar::SerializeToTOML().find("test_int32_flag") == std::string::npos);
}
SECTION("recorded but not applied over a user-set value") {
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "8"));
CHECK_FALSE(rex::cvar::SetSessionDefault("test_int32_flag", "77"));
CHECK(REXCVAR_GET(test_int32_flag) == 8);
auto* info = rex::cvar::GetFlagInfo("test_int32_flag");
REQUIRE(info != nullptr);
CHECK(info->has_session_default);
CHECK(info->session_default == "77");
}
SECTION("user value wins even when it equals the registered default") {
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "42")); // == default
CHECK_FALSE(rex::cvar::SetSessionDefault("test_int32_flag", "77"));
CHECK(REXCVAR_GET(test_int32_flag) == 42);
}
SECTION("driver is recorded for UI display") {
REQUIRE(rex::cvar::SetSessionDefault("test_string_flag", "driven", "test_bool_flag"));
auto* info = rex::cvar::GetFlagInfo("test_string_flag");
REQUIRE(info != nullptr);
CHECK(info->session_driver == "test_bool_flag");
}
SECTION("constraints still validate") {
CHECK_FALSE(rex::cvar::SetSessionDefault("test_ranged_flag", "100"));
CHECK(REXCVAR_GET(test_ranged_flag) == 5);
}
SECTION("ResetToDefault returns to the session default and drops the save line") {
REQUIRE(rex::cvar::SetSessionDefault("test_int32_flag", "77"));
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "8"));
CHECK(rex::cvar::SerializeToTOML().find("test_int32_flag") != std::string::npos);
rex::cvar::ResetToDefault("test_int32_flag");
CHECK(REXCVAR_GET(test_int32_flag) == 77);
CHECK_FALSE(rex::cvar::IsUserSet("test_int32_flag"));
CHECK(rex::cvar::SerializeToTOML().find("test_int32_flag") == std::string::npos);
}
}
TEST_CASE("cvar saves preserve the user's value over later code forcing", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// The user chooses a value (toml / settings UI)...
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "8"));
// ...then feature code forces the live value (enforcement, e.g.
// performance mode re-asserting a diagnostic off). The live value
// changes; the saved value must stay the user's choice.
REXCVAR_SET(test_int32_flag, 0);
CHECK(REXCVAR_GET(test_int32_flag) == 0);
auto toml = rex::cvar::SerializeToTOML();
CHECK(toml.find("test_int32_flag = 8") != std::string::npos);
CHECK(toml.find("test_int32_flag = 0") == std::string::npos);
}
TEST_CASE("cvar TOML serialization escapes string values", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// A Windows path with backslashes and quotes: unescaped, this voids the
// whole config file with a parse error on the next load.
const std::string nasty = "C:\\Games\\ac6 \"test\".iso";
REQUIRE(rex::cvar::SetFlagByName("test_string_flag", nasty));
auto toml = rex::cvar::SerializeToTOML();
CHECK(toml.find("\\\\") != std::string::npos);
SECTION("serialized output round-trips through LoadConfig") {
auto config_path = std::filesystem::temp_directory_path() / "test_escape.toml";
{
std::ofstream file(config_path);
file << toml;
}
rex::cvar::testing::ResetAllForTesting();
CHECK(REXCVAR_GET(test_string_flag) == "default");
rex::cvar::LoadConfig(config_path);
std::filesystem::remove(config_path);
CHECK(REXCVAR_GET(test_string_flag) == nasty);
}
}
TEST_CASE("cvar metadata integration test", "[cvar][integration]") {
@@ -466,9 +608,10 @@ TEST_CASE("cvar ResetAllToDefaults", "[cvar]") {
TEST_CASE("cvar SerializeToTOML with category filter", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// Modify flags in different categories
REXCVAR_SET(test_int32_flag, 123); // Category: Test
REXCVAR_SET(test_category_flag, "changed"); // Category: TestCategory
// Modify flags in different categories (via the user path: serialization
// persists user-set flags only)
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "123")); // Category: Test
REQUIRE(rex::cvar::SetFlagByName("test_category_flag", "changed")); // Category: TestCategory
SECTION("Filter by category returns only that category") {
auto test_toml = rex::cvar::SerializeToTOML("Test");
@@ -491,9 +634,9 @@ TEST_CASE("cvar SaveConfig", "[cvar]") {
// Clean up any existing file
std::filesystem::remove(save_path);
SECTION("SaveConfig writes modified flags to file") {
REXCVAR_SET(test_int32_flag, 777);
REXCVAR_SET(test_string_flag, "saved_value");
SECTION("SaveConfig writes user-set flags to file") {
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "777"));
REQUIRE(rex::cvar::SetFlagByName("test_string_flag", "saved_value"));
rex::cvar::SaveConfig(save_path);