From 5eb1ad0dda929896b2199a90274b29e035172394 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sun, 24 May 2026 23:22:27 +0000 Subject: [PATCH 01/17] Initial plan From 30afd2cdb263ab8a317bc1bc1e42ccd4c6aeb2d7 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sun, 24 May 2026 23:33:30 +0000 Subject: [PATCH 02/17] refactor(settings): boot snapshot restart-field infra + MCP introspection Co-authored-by: alandtse <7086117+alandtse@users.noreply.github.com> --- .claude/CLAUDE.md | 1 + src/Feature.h | 12 +++ src/Features/RemoteControl.cpp | 118 +++++++++++++++++++++++- src/Features/Upscaling.cpp | 42 ++++----- src/Features/Upscaling.h | 23 +++++ src/Features/Upscaling/DLSSperf.cpp | 7 +- src/Features/Upscaling/DLSSperf.h | 11 --- src/Features/Upscaling/Streamline.cpp | 2 +- src/Hooks.cpp | 5 ++ src/Utils/BootSnapshot.h | 123 ++++++++++++++++++++++++++ src/Utils/RestartSettings.h | 40 +++++++++ src/Utils/UI.h | 69 +++++++++++++++ tests/cpp/CMakeLists.txt | 1 + tests/cpp/test_bootsnapshot.cpp | 64 ++++++++++++++ 14 files changed, 474 insertions(+), 44 deletions(-) create mode 100644 src/Utils/BootSnapshot.h create mode 100644 src/Utils/RestartSettings.h create mode 100644 tests/cpp/test_bootsnapshot.cpp diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1a4f89c619..e42f3df582 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -373,6 +373,7 @@ Feature versions are automatically extracted from `.ini` files and compiled into - JSON-based settings with nlohmann_json - Hot-reload capability through ImGui interface - Versioned feature configurations for compatibility +- Restart-gated fields use `Util::Settings::BootSnapshot` + `kRestartFields` metadata to diff boot-latched vs selected values (drives `Util::Text::RestartNeeded` banners and MCP introspection) ### Error Handling diff --git a/src/Feature.h b/src/Feature.h index 95ca1741a7..c6aeaeaf43 100644 --- a/src/Feature.h +++ b/src/Feature.h @@ -3,6 +3,10 @@ #include "FeatureCategories.h" #include "FeatureConstraints.h" #include "FeatureVersions.h" +#include "Utils/RestartSettings.h" + +#include +#include #ifdef TRACY_ENABLE # include # include @@ -21,6 +25,14 @@ struct Feature // Override in features to expose settings for search virtual std::vector GetSettingsSearchEntries() { return {}; } + // Restart-required settings introspection. Default: none. + // Features with restart-gated fields override these to expose them to UI + // helpers and MCP/RemoteControl without per-feature glue. + virtual std::span GetRestartRequiredFields() const { return {}; } + virtual const void* GetBootValue(std::string_view /*jsonKey*/) const { return nullptr; } + virtual const void* GetSettingsBlob() const { return nullptr; } + virtual size_t GetSettingsBlobSize() const { return 0; } + // Nexus Mods base URL for Skyrim Special Edition static constexpr std::string_view NEXUS_BASE_URL = "https://www.nexusmods.com/skyrimspecialedition/mods/"; bool loaded = false; diff --git a/src/Features/RemoteControl.cpp b/src/Features/RemoteControl.cpp index bda02d65e2..a6c259f30c 100644 --- a/src/Features/RemoteControl.cpp +++ b/src/Features/RemoteControl.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -463,6 +464,19 @@ static mcp::json FeatureEntry(Feature* f) }); } +static std::string BytesToHex(const void* data, size_t size) +{ + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(size * 2); + const auto* bytes = reinterpret_cast(data); + for (size_t i = 0; i < size; ++i) { + out.push_back(kHex[(bytes[i] >> 4) & 0xF]); + out.push_back(kHex[bytes[i] & 0xF]); + } + return out; +} + void RemoteControl::RegisterInspectTool() { // Single read endpoint for non-feature engine state. Kind-discriminated @@ -518,6 +532,14 @@ void RemoteControl::RegisterFeatureTool() " list — no other params. Returns a JSON array; " "each entry has { name, shortName, loaded, version, " "category, isCore, supportsVR, inMenu }.\n" + " list_restart_required — params: shortName (optional). " + "Returns a JSON array of restart-gated setting keys. " + "If shortName is omitted, returns entries for all " + "features.\n" + " list_pending_restart — params: shortName (optional). " + "Returns a JSON array of restart-gated keys whose " + "live value differs from the boot-latched value. " + "Entries include boot/live bytes as hex.\n" " get — params: shortName. Returns the " "Feature::SaveSettings(json) blob. May return null " "if the feature has no SaveSettings/LoadSettings " @@ -557,7 +579,8 @@ void RemoteControl::RegisterFeatureTool() "has a hook that isn't gated on `loaded` — file an " "issue with the shortName.") .with_string_param("action", - "One of: 'list', 'get', 'set', 'reset', 'toggle'.") + "One of: 'list', 'list_restart_required', " + "'list_pending_restart', 'get', 'set', 'reset', 'toggle'.") .with_string_param("shortName", "Required for all actions except 'list'. From the " "list response.", @@ -588,6 +611,97 @@ void RemoteControl::RegisterFeatureTool() } const std::string shortName = params.value("shortName", std::string{}); + + const auto findFeatureAnyState = [&](const std::string& name) -> Feature* { + for (auto* f : Feature::GetFeatureList()) { + if (f->GetShortName() == name) { + return f; + } + } + return nullptr; + }; + + if (action == "list_restart_required") { + mcp::json out = mcp::json::array(); + if (!shortName.empty()) { + auto* f = findFeatureAnyState(shortName); + if (!f) { + return ErrorResult("feature not found", { { "shortName", shortName } }); + } + for (const auto& field : f->GetRestartRequiredFields()) { + out.push_back(mcp::json({ + { "feature", f->GetShortName() }, + { "key", field.jsonKey }, + { "label", field.label }, + })); + } + return TextResult(out.dump()); + } + + for (auto* f : Feature::GetFeatureList()) { + for (const auto& field : f->GetRestartRequiredFields()) { + out.push_back(mcp::json({ + { "feature", f->GetShortName() }, + { "key", field.jsonKey }, + { "label", field.label }, + })); + } + } + return TextResult(out.dump()); + } + + if (action == "list_pending_restart") { + mcp::json out = mcp::json::array(); + const auto emitPending = [&](Feature* f) { + const auto fields = f->GetRestartRequiredFields(); + if (fields.empty()) { + return; + } + const auto* liveBase = reinterpret_cast(f->GetSettingsBlob()); + const size_t liveSize = f->GetSettingsBlobSize(); + if (!liveBase || liveSize == 0) { + return; + } + for (const auto& field : fields) { + if (!field.jsonKey || field.size == 0) { + continue; + } + if (field.offset + field.size > liveSize) { + continue; + } + const void* boot = f->GetBootValue(field.jsonKey); + if (!boot) { + continue; + } + const void* live = liveBase + field.offset; + if (std::memcmp(boot, live, field.size) != 0) { + out.push_back(mcp::json({ + { "feature", f->GetShortName() }, + { "key", field.jsonKey }, + { "label", field.label }, + { "size", field.size }, + { "boot_hex", BytesToHex(boot, field.size) }, + { "live_hex", BytesToHex(live, field.size) }, + })); + } + } + }; + + if (!shortName.empty()) { + auto* f = Feature::FindFeatureByShortName(shortName); + if (!f) { + return ErrorResult("feature not found or not loaded", { { "shortName", shortName } }); + } + emitPending(f); + return TextResult(out.dump()); + } + + Feature::ForEachLoadedFeature("RemoteControlPendingRestart", [&](Feature* f) { + emitPending(f); + }); + return TextResult(out.dump()); + } + if (shortName.empty()) { return ErrorResult("missing required parameter 'shortName'", { { "action", action } }); @@ -717,7 +831,7 @@ void RemoteControl::RegisterFeatureTool() return ErrorResult("unknown action", { { "action", action }, - { "supported", mcp::json::array({ "list", "get", "set", "reset", "toggle" }) } }); + { "supported", mcp::json::array({ "list", "list_restart_required", "list_pending_restart", "get", "set", "reset", "toggle" }) } }); }); } diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index bcb02a3b06..a9b6b45fdd 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -228,9 +228,9 @@ void Upscaling::DrawSettings() // path mode slot (upscaleMethod, not upscaleMethodNoDLSS), since // that's the one the boot snapshot locked. if (currentUpscaleMode == &settings.upscaleMethod && - settings.upscaleMethod != dlssPerf.GetBootUpscaleMethod()) { + bootSnapshot.HasPendingChange(settings, &Settings::upscaleMethod)) { const uint live = std::clamp(settings.upscaleMethod, 0u, availableModes); - const uint boot = std::clamp(dlssPerf.GetBootUpscaleMethod(), 0u, availableModes); + const uint boot = std::clamp(bootSnapshot.Boot(&Settings::upscaleMethod), 0u, availableModes); Util::Text::RestartNeeded( "Pending restart: currently active method = %s (selected = %s).", upscaleModes[boot].c_str(), upscaleModes[live].c_str()); @@ -280,9 +280,9 @@ void Upscaling::DrawSettings() // Pending-diff vs the boot snapshot the runtime upscaler is // actually using. Without this the slider change looks like a // no-op. - if (dlssPerf.HasBootSnapshot() && - settings.qualityMode != dlssPerf.GetBootQualityMode()) { - const uint bm = std::clamp(dlssPerf.GetBootQualityMode(), 0u, 4u); + if (dlssPerf.IsHookActive() && + bootSnapshot.HasPendingChange(settings, &Settings::qualityMode)) { + const uint bm = std::clamp(bootSnapshot.Boot(&Settings::qualityMode), 0u, 4u); const char* bootLabel = (upscaleMethod == UpscaleMethod::kDLSS) ? upscalePresetsDLSS[std::clamp(4 - (int)bm, 0, 4)] : upscalePresets[std::clamp(4 - (int)bm, 0, 4)]; Util::Text::RestartNeeded( "Pending restart: currently active = %s ( %.2fx ). Change applies after game restart.", @@ -336,9 +336,8 @@ void Upscaling::DrawSettings() } if (!dlssAvailable && settings.enableDLSSperf) Util::Text::Disabled("DLSSperf requires DLSS — switch upscaler Method to DLSS to activate."); - if (dlssAvailable && settings.enableDLSSperf != globals::features::upscaling.dlssPerf.IsHookActive()) - Util::Text::RestartNeeded("Pending restart: DLSSperf will %s on next launch.", - settings.enableDLSSperf ? "enable" : "disable"); + if (dlssAvailable) + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::enableDLSSperf); } } @@ -351,37 +350,23 @@ void Upscaling::DrawSettings() if (HasFrameGenModule()) ImGui::Text("AMD FSR Frame Generation is available."); ImGui::Text("Requires a D3D11 to D3D12 proxy which can create compatibility issues"); - ImGui::Text("Toggling this setting requires a restart to work correctly"); - - bool onlyRequiresRestart = true; if (!isWindowed) { Util::Text::Warning("Warning: Requires windowed mode"); - - onlyRequiresRestart = false; } if (lowRefreshRate && !settings.frameGenerationForceEnable) { Util::Text::Warning("Warning: Requires a high refresh rate monitor or Force Enable Frame Generation"); - - onlyRequiresRestart = false; } if (fidelityFXMissing) { Util::Text::Warning("Warning: FidelityFX DLLs are not loaded"); - - onlyRequiresRestart = false; } - if (onlyRequiresRestart && settings.frameGenerationMode && !frameGenerationDx12PathActive) - Util::Text::Warning("Warning: Requires restart"); - - if (!settings.frameGenerationMode && frameGenerationDx12PathActive) - Util::Text::Warning("Warning: Requires restart"); - bool fgEnabled = settings.frameGenerationMode != 0; if (ImGui::Checkbox("Frame Generation", &fgEnabled)) settings.frameGenerationMode = fgEnabled ? 1 : 0; + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::frameGenerationMode); if (!frameGenerationDx12PathActive) ImGui::BeginDisabled(); @@ -397,6 +382,7 @@ void Upscaling::DrawSettings() bool fgForce = settings.frameGenerationForceEnable != 0; if (ImGui::Checkbox("Force Enable Frame Generation", &fgForce)) settings.frameGenerationForceEnable = fgForce ? 1 : 0; + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::frameGenerationForceEnable); ImGui::Checkbox("Frame Generation in Menus", &settings.frameGenerationAllowInMenus); if (auto _tt = Util::HoverTooltipWrapper()) { @@ -592,6 +578,10 @@ void Upscaling::LoadSettings(json& o_json) logger::warn("[Upscaling] Loaded upscaleMethodNoDLSS {} out of range, clamping to {}", settings.upscaleMethodNoDLSS, enumCount ? enumCount - 1 : 0); settings.upscaleMethodNoDLSS = enumCount ? enumCount - 1 : 0; } + if (settings.qualityMode > 4) { + logger::warn("[Upscaling] Loaded qualityMode {} out of range, clamping to 4", settings.qualityMode); + settings.qualityMode = 4; + } if (settings.presetDLSS > 4) { logger::warn("[Upscaling] Loaded presetDLSS {} out of range, resetting to 0 (Default)", settings.presetDLSS); settings.presetDLSS = 0; @@ -715,8 +705,8 @@ Upscaling::UpscaleMethod Upscaling::GetUpscaleMethod() const // Lock runtime to the boot upscaler under DLSSperf — engine RTs are // sized for it, and routing a different method through testTexture/ // renderRes paths breaks the HMD. - if (globals::features::upscaling.dlssPerf.HasBootSnapshot()) - return (UpscaleMethod)globals::features::upscaling.dlssPerf.GetBootUpscaleMethod(); + if (globals::features::upscaling.dlssPerf.IsHookActive()) + return static_cast(bootSnapshot.Boot(&Settings::upscaleMethod)); if (streamline.featureDLSS) return (UpscaleMethod)settings.upscaleMethod; return (UpscaleMethod)settings.upscaleMethodNoDLSS; @@ -1337,7 +1327,7 @@ void Upscaling::ConfigureUpscaling(RE::BSGraphics::State* a_viewport) } else { // Boot qualityMode under DLSSperf so projection stays coherent // with the engine RTs sized at install. - const uint32_t qm = globals::features::upscaling.dlssPerf.HasBootSnapshot() ? globals::features::upscaling.dlssPerf.GetBootQualityMode() : settings.qualityMode; + const uint32_t qm = globals::features::upscaling.dlssPerf.IsHookActive() ? bootSnapshot.Boot(&Settings::qualityMode) : settings.qualityMode; float resolutionScaleBase = 1.0f / ffxFsr3GetUpscaleRatioFromQualityMode((FfxFsr3QualityMode)qm); auto renderWidth = static_cast(screenWidth * resolutionScaleBase); diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index cc9abab8d7..74e9d0f2ab 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -6,6 +6,7 @@ #include "Upscaling/FidelityFX.h" #include "Upscaling/RCAS/RCAS.h" #include "Upscaling/Streamline.h" +#include "Utils/BootSnapshot.h" #include #include #include @@ -80,6 +81,16 @@ struct Upscaling : Feature Settings settings; + inline static constexpr Util::Settings::RestartTable kRestartFields{ { + UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), + UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), + UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), + UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), + UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), + } }; + + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + struct JitterCB { float2 jitter; @@ -116,6 +127,18 @@ struct Upscaling : Feature bool IsUpscalingActive() const; // Feature interface overrides + std::span GetRestartRequiredFields() const override + { + // Frame generation + DLSSperf enable are always restart-gated. + // Method/preset are restart-gated only while DLSSperf's VR hook is active. + const size_t baseCount = 3; + const size_t extra = dlssPerf.IsHookActive() ? 2 : 0; + return { kRestartFields.data(), baseCount + extra }; + } + const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } + const void* GetSettingsBlob() const override { return &settings; } + size_t GetSettingsBlobSize() const override { return sizeof(settings); } + virtual void DrawSettings() override; virtual void SaveSettings(json& o_json) override; virtual void LoadSettings(json& o_json) override; diff --git a/src/Features/Upscaling/DLSSperf.cpp b/src/Features/Upscaling/DLSSperf.cpp index 3983da4175..713104ef79 100644 --- a/src/Features/Upscaling/DLSSperf.cpp +++ b/src/Features/Upscaling/DLSSperf.cpp @@ -139,10 +139,9 @@ void DLSSperf::InstallRenderTargetSizeHook() renderEyeWidth = std::max(1, (uint32_t)(w / scale)); renderEyeHeight = std::max(1, (uint32_t)(h / scale)); - // Boot snapshot — runtime upscaler paths read these; UI keeps editing - // live `settings` for JSON persistence. - bootUpscaleMethod = globals::features::upscaling.settings.upscaleMethod; - bootQualityMode = qualityMode; + // Restart-required settings snapshot is latched by the render-target + // creation hook, but keep this robust to call-order changes. + globals::features::upscaling.bootSnapshot.LatchIfNeeded(globals::features::upscaling.settings); stl::write_vfunc<0x12, GetRenderTargetSize_Hook>(RE::VTABLE_BSOpenVR[0]); diff --git a/src/Features/Upscaling/DLSSperf.h b/src/Features/Upscaling/DLSSperf.h index 22d3f74414..a5a331b2f1 100644 --- a/src/Features/Upscaling/DLSSperf.h +++ b/src/Features/Upscaling/DLSSperf.h @@ -57,13 +57,6 @@ struct DLSSperf uint32_t GetRenderEyeWidth() const { return renderEyeWidth; } uint32_t GetRenderEyeHeight() const { return renderEyeHeight; } - // Boot snapshots — engine RTs are sized once against these, so runtime - // upscaler reads must route through here instead of live `Upscaling:: - // settings` (mid-session UI changes would otherwise break the HMD). - bool HasBootSnapshot() const { return hookActive; } - uint32_t GetBootUpscaleMethod() const { return bootUpscaleMethod; } - uint32_t GetBootQualityMode() const { return bootQualityMode; } - // Phase 3: real HMD display resolution in SBS format (e.g. 3072×1632) // Used by Upscaling pipeline to override polluted screenSize (which equals RenderRes after hook) float2 GetDisplayScreenSize() const @@ -208,10 +201,6 @@ struct DLSSperf uint32_t renderEyeWidth = 0; uint32_t renderEyeHeight = 0; - // Boot snapshot — see HasBootSnapshot() accessor above. - uint32_t bootUpscaleMethod = 0; - uint32_t bootQualityMode = 0; - // Phase 2: vtable hook for BSOpenVR::GetRenderTargetSize (vfunc 0x12) struct GetRenderTargetSize_Hook { diff --git a/src/Features/Upscaling/Streamline.cpp b/src/Features/Upscaling/Streamline.cpp index 30d29262f2..c5c2a812a2 100644 --- a/src/Features/Upscaling/Streamline.cpp +++ b/src/Features/Upscaling/Streamline.cpp @@ -423,7 +423,7 @@ void Streamline::SetDLSSOptions(sl::ViewportHandle p_viewport, uint32_t width) // Boot qualityMode under DLSSperf — DLSS dispatch must match the // renderRes the engine was sized for at install. - uint32_t qualityMode = globals::features::upscaling.dlssPerf.HasBootSnapshot() ? globals::features::upscaling.dlssPerf.GetBootQualityMode() : globals::features::upscaling.settings.qualityMode; + uint32_t qualityMode = globals::features::upscaling.dlssPerf.IsHookActive() ? globals::features::upscaling.bootSnapshot.Boot(&Upscaling::Settings::qualityMode) : globals::features::upscaling.settings.qualityMode; switch (qualityMode) { case 1: dlssOptions.mode = sl::DLSSMode::eMaxQuality; diff --git a/src/Hooks.cpp b/src/Hooks.cpp index c71636c886..5c9d4abb17 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -370,6 +370,11 @@ struct BSShaderRenderTargets_Create { Util::SetGameSettingValue("iNumFocusShadow:Display", iNumFocusShadow, 0); + // Restart-required settings snapshot. Latch once as soon as engine + // rendering state begins initializing (pre-RT allocation) so UI/MCP + // can diff "active at boot" vs "selected". + globals::features::upscaling.bootSnapshot.LatchIfNeeded(globals::features::upscaling.settings); + // DLSSperf: install the BSOpenVR render-target-size hook before the // engine creates its render targets. This is the only place where // BSOpenVR is guaranteed available AND we can still influence RT diff --git a/src/Utils/BootSnapshot.h b/src/Utils/BootSnapshot.h new file mode 100644 index 0000000000..133a22e80e --- /dev/null +++ b/src/Utils/BootSnapshot.h @@ -0,0 +1,123 @@ +#pragma once + +#include "Utils/RestartSettings.h" + +#include +#include +#include +#include +#include + +namespace Util::Settings +{ + namespace detail + { + template + size_t MemberOffset(T SettingsT::*member) noexcept + { + static_assert(std::is_default_constructible_v); + SettingsT tmp{}; + const auto* base = reinterpret_cast(&tmp); + const auto* field = reinterpret_cast(&(tmp.*member)); + return static_cast(field - base); + } + } + + template + class BootSnapshot + { + public: + template + explicit constexpr BootSnapshot(const RestartTable& table) noexcept : + table_(table.data()), tableSize_(N) + { + static_assert(std::is_standard_layout_v, "BootSnapshot requires standard-layout Settings for offsetof-based tables."); + static_assert(std::is_trivially_copyable_v, "BootSnapshot requires trivially-copyable Settings."); + } + + void Latch(const SettingsT& live) noexcept + { + bootCopy_ = live; + latched_ = true; + } + + void LatchIfNeeded(const SettingsT& live) noexcept + { + if (!latched_) { + Latch(live); + } + } + + bool IsLatched() const noexcept { return latched_; } + + std::span Fields() const noexcept + { + return { table_, tableSize_ }; + } + + const void* RawBoot(std::string_view jsonKey) const noexcept + { + if (!latched_) { + return nullptr; + } + const auto* field = FindRestartField(Fields(), jsonKey); + if (!field) { + return nullptr; + } + return reinterpret_cast(&bootCopy_) + field->offset; + } + + template + const T& Boot(T SettingsT::*member) const noexcept + { + static const T kZero{}; + if (!latched_) { + return kZero; + } + const size_t offset = detail::MemberOffset(member); + return *reinterpret_cast(reinterpret_cast(&bootCopy_) + offset); + } + + template + bool HasPendingChange(const SettingsT& live, T SettingsT::*member) const noexcept + { + if (!latched_) { + return false; + } + const size_t offset = detail::MemberOffset(member); + return std::memcmp(reinterpret_cast(&bootCopy_) + offset, + reinterpret_cast(&live) + offset, + sizeof(T)) != 0; + } + + bool HasPendingChange(const SettingsT& live, const RestartFieldInfo& field) const noexcept + { + if (!latched_ || !field.jsonKey) { + return false; + } + return std::memcmp(reinterpret_cast(&bootCopy_) + field.offset, + reinterpret_cast(&live) + field.offset, + field.size) != 0; + } + + template + const RestartFieldInfo* FindField(T SettingsT::*member) const noexcept + { + const size_t offset = detail::MemberOffset(member); + const size_t size = sizeof(T); + for (const auto& field : Fields()) { + if (field.offset == offset && field.size == size) { + return &field; + } + } + return nullptr; + } + + private: + SettingsT bootCopy_{}; + const RestartFieldInfo* table_ = nullptr; + size_t tableSize_ = 0; + bool latched_ = false; + }; +} + diff --git a/src/Utils/RestartSettings.h b/src/Utils/RestartSettings.h new file mode 100644 index 0000000000..1a5f217b82 --- /dev/null +++ b/src/Utils/RestartSettings.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace Util::Settings +{ + // Type-erased field descriptor for restart-gated settings. + // + // `jsonKey` must match the NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE field name so + // MCP/RemoteControl can refer to it without per-feature glue. + struct RestartFieldInfo + { + const char* jsonKey = nullptr; + const char* label = nullptr; + size_t offset = 0; + size_t size = 0; + }; + + template + using RestartTable = std::array; + + inline constexpr const RestartFieldInfo* FindRestartField(std::span fields, std::string_view jsonKey) noexcept + { + for (const auto& field : fields) { + if (field.jsonKey && jsonKey == field.jsonKey) { + return &field; + } + } + return nullptr; + } +} + +// Convenience macro for building a RestartFieldInfo entry without duplicating +// the member name string. Requires SettingsT to be standard-layout. +#define UTIL_RESTART_FIELD(SettingsT, member, userLabel) \ + Util::Settings::RestartFieldInfo{ #member, userLabel, offsetof(SettingsT, member), sizeof(decltype(SettingsT::member)) } + diff --git a/src/Utils/UI.h b/src/Utils/UI.h index 177a8970c3..ccd1e95004 100644 --- a/src/Utils/UI.h +++ b/src/Utils/UI.h @@ -5,11 +5,14 @@ #include #include #include +#include +#include #include #include // For WPARAM and virtual key constants #include "../FeatureConstraints.h" #include "../Menu/Fonts.h" +#include "Utils/BootSnapshot.h" #include "Utils/Input.h" // Forward declarations @@ -957,6 +960,72 @@ namespace Util void WrappedRestartNeeded(const char* fmt, ...) IM_FMTARGS(1); } + // Restart-required settings UI helpers. + namespace UI + { + template + inline void DrawSettingDiff(const Util::Settings::BootSnapshot& snapshot, const SettingsT& live, T SettingsT::*field) + { + if (!snapshot.IsLatched()) { + return; + } + const auto* info = snapshot.FindField(field); + if (!info) { + return; + } + if (!snapshot.HasPendingChange(live, field)) { + return; + } + + if constexpr (std::is_same_v) { + const bool boot = snapshot.Boot(field); + Util::Text::RestartNeeded( + "Pending restart: %s changed (active = %s, selected = %s).", + info->label, + boot ? "on" : "off", + (live.*field) ? "on" : "off"); + return; + } + if constexpr (std::is_integral_v) { + const auto boot = static_cast(snapshot.Boot(field)); + const auto selected = static_cast(live.*field); + Util::Text::RestartNeeded( + "Pending restart: %s changed (active = %lld, selected = %lld).", + info->label, + boot, + selected); + return; + } + if constexpr (std::is_floating_point_v) { + const double boot = static_cast(snapshot.Boot(field)); + const double selected = static_cast(live.*field); + Util::Text::RestartNeeded( + "Pending restart: %s changed (active = %.3f, selected = %.3f).", + info->label, + boot, + selected); + return; + } + + Util::Text::RestartNeeded("Pending restart: %s changed.", info->label); + } + + template + inline void DrawPendingBanners(const Util::Settings::BootSnapshot& snapshot, + const SettingsT& live, + std::span fields) + { + if (!snapshot.IsLatched()) { + return; + } + for (const auto& field : fields) { + if (snapshot.HasPendingChange(live, field)) { + Util::Text::RestartNeeded("Pending restart: %s changed.", field.label); + } + } + } + } + /** * @brief Input handling utilities for ImGui integration * diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt index 9ec4f08996..769dcc84d0 100644 --- a/tests/cpp/CMakeLists.txt +++ b/tests/cpp/CMakeLists.txt @@ -38,6 +38,7 @@ find_package(imgui CONFIG REQUIRED) add_executable(cpp_tests test_main.cpp + test_bootsnapshot.cpp test_subrect.cpp # Compile the unit-under-test directly into the test binary so we don't # depend on the plugin DLL build (which pulls in FFX/Streamline/etc.). diff --git a/tests/cpp/test_bootsnapshot.cpp b/tests/cpp/test_bootsnapshot.cpp new file mode 100644 index 0000000000..a2ef9c7373 --- /dev/null +++ b/tests/cpp/test_bootsnapshot.cpp @@ -0,0 +1,64 @@ +// Unit tests for Util::Settings::BootSnapshot (restart-required settings diff). + +#include "Utils/BootSnapshot.h" + +#include + +#include + +namespace +{ + struct TestSettings + { + uint32_t mode = 0; + bool enabled = false; + float value = 0.0f; + }; + + inline constexpr Util::Settings::RestartTable kFields{ { + UTIL_RESTART_FIELD(TestSettings, mode, "Mode"), + UTIL_RESTART_FIELD(TestSettings, enabled, "Enabled"), + } }; +} + +TEST_CASE("BootSnapshot starts unlatching and ignores diffs", "[bootsnapshot]") +{ + Util::Settings::BootSnapshot snap{ kFields }; + TestSettings live{}; + live.mode = 3; + live.enabled = true; + + REQUIRE_FALSE(snap.IsLatched()); + REQUIRE(snap.RawBoot("mode") == nullptr); + REQUIRE_FALSE(snap.HasPendingChange(live, &TestSettings::mode)); +} + +TEST_CASE("BootSnapshot detects member changes after latch", "[bootsnapshot]") +{ + Util::Settings::BootSnapshot snap{ kFields }; + TestSettings boot{}; + boot.mode = 1; + boot.enabled = false; + + snap.Latch(boot); + REQUIRE(snap.IsLatched()); + REQUIRE(snap.Boot(&TestSettings::mode) == 1); + REQUIRE(snap.Boot(&TestSettings::enabled) == false); + + TestSettings live = boot; + REQUIRE_FALSE(snap.HasPendingChange(live, &TestSettings::mode)); + + live.mode = 2; + REQUIRE(snap.HasPendingChange(live, &TestSettings::mode)); + REQUIRE_FALSE(snap.HasPendingChange(live, &TestSettings::enabled)); +} + +TEST_CASE("BootSnapshot exposes field metadata by member", "[bootsnapshot]") +{ + Util::Settings::BootSnapshot snap{ kFields }; + const auto* info = snap.FindField(&TestSettings::enabled); + REQUIRE(info != nullptr); + REQUIRE(std::string_view(info->jsonKey) == "enabled"); + REQUIRE(std::string_view(info->label) == "Enabled"); +} + From d51f41445c0b033c96e24838eaea509b6e73cdbd Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sun, 24 May 2026 23:33:49 +0000 Subject: [PATCH 03/17] docs: note BootSnapshot canary usage Co-authored-by: alandtse <7086117+alandtse@users.noreply.github.com> --- .claude/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e42f3df582..c4cb716895 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -373,7 +373,7 @@ Feature versions are automatically extracted from `.ini` files and compiled into - JSON-based settings with nlohmann_json - Hot-reload capability through ImGui interface - Versioned feature configurations for compatibility -- Restart-gated fields use `Util::Settings::BootSnapshot` + `kRestartFields` metadata to diff boot-latched vs selected values (drives `Util::Text::RestartNeeded` banners and MCP introspection) +- Restart-gated fields use `Util::Settings::BootSnapshot` + `kRestartFields` metadata to diff boot-latched vs selected values (drives `Util::Text::RestartNeeded` banners and MCP introspection; see Upscaling for a canary) ### Error Handling From 95a0d6bcdbb1cc2d9c64070564308a9e2d668c4e Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sun, 24 May 2026 23:53:14 +0000 Subject: [PATCH 04/17] refactor(settings): cover DynamicCubemaps/VL + restart-needed cleanup Co-authored-by: alandtse <7086117+alandtse@users.noreply.github.com> --- src/Features/DynamicCubemaps.cpp | 19 ++++++++------ src/Features/DynamicCubemaps.h | 17 ++++++++++++- src/Features/RenderDoc.cpp | 2 +- src/Features/Upscaling.cpp | 2 ++ src/Features/Upscaling.h | 35 ++++++++++++++++++++------ src/Features/Upscaling/Streamline.h | 1 - src/Features/VRStereoOptimizations.cpp | 4 +-- src/Features/VolumetricLighting.cpp | 8 +++++- src/Features/VolumetricLighting.h | 17 ++++++++++++- src/Features/WeatherEditor.cpp | 2 +- src/WeatherEditor/EditorWindow.cpp | 6 +---- 11 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/Features/DynamicCubemaps.cpp b/src/Features/DynamicCubemaps.cpp index 560c902452..215299beac 100644 --- a/src/Features/DynamicCubemaps.cpp +++ b/src/Features/DynamicCubemaps.cpp @@ -30,13 +30,16 @@ void DynamicCubemaps::DrawSettings() recompileFlag |= ImGui::Checkbox("Enable Screen Space Reflections", reinterpret_cast(&settings.EnabledSSR)); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Enable Screen Space Reflections on Water"); - if (REL::Module::IsVR() && !enabledAtBoot) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.0f, 0.0f, 1.0f)); - ImGui::Text( - "A restart is required to enable in VR. " - "Save Settings after enabling and restart the game."); - ImGui::PopStyleColor(); - } + } + if (REL::Module::IsVR() && + bootSnapshot.IsLatched() && + bootSnapshot.HasPendingChange(settings, &Settings::EnabledSSR)) { + const bool active = bootSnapshot.Boot(&Settings::EnabledSSR) != 0; + const bool selected = settings.EnabledSSR != 0; + Util::Text::RestartNeeded( + "Pending restart: Screen Space Reflections changed (active = %s, selected = %s).", + active ? "on" : "off", + selected ? "on" : "off"); } ImGui::TreePop(); } @@ -167,6 +170,7 @@ void DynamicCubemaps::DataLoaded() void DynamicCubemaps::PostPostLoad() { + bootSnapshot.LatchIfNeeded(settings); if (REL::Module::IsVR() && settings.EnabledSSR) { std::map earlyhiddenVRCubeMapSettings{ { "bScreenSpaceReflectionEnabled:Display", 0x1ED5BC0 }, @@ -180,7 +184,6 @@ void DynamicCubemaps::PostPostLoad() *setting = true; } } - enabledAtBoot = true; } } diff --git a/src/Features/DynamicCubemaps.h b/src/Features/DynamicCubemaps.h index 7b23744d41..f4913a3b8c 100644 --- a/src/Features/DynamicCubemaps.h +++ b/src/Features/DynamicCubemaps.h @@ -1,6 +1,7 @@ #pragma once #include "Buffer.h" +#include "Utils/BootSnapshot.h" class MenuOpenCloseEventHandler : public RE::BSTEventSink { @@ -119,7 +120,21 @@ struct DynamicCubemaps : Feature }; Settings settings; - bool enabledAtBoot = false; + + inline static constexpr Util::Settings::RestartTable kRestartFields{ { + UTIL_RESTART_FIELD(Settings, EnabledSSR, "Screen Space Reflections"), + } }; + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + + std::span GetRestartRequiredFields() const override + { + // VR-only: enabling SSR needs game-setting initialization at startup. + return REL::Module::IsVR() ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; + } + const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } + const void* GetSettingsBlob() const override { return &settings; } + size_t GetSettingsBlobSize() const override { return sizeof(settings); } + void UpdateCubemap(); void PostDeferred(); diff --git a/src/Features/RenderDoc.cpp b/src/Features/RenderDoc.cpp index 88011ba6b2..1759d8794a 100644 --- a/src/Features/RenderDoc.cpp +++ b/src/Features/RenderDoc.cpp @@ -155,7 +155,7 @@ void RenderDoc::DrawSettings() const auto& themeSettings = Menu::GetSingleton()->GetTheme(); if (renderDocCaptureEnabled && !renderDocActive) { - ImGui::TextColored(themeSettings.StatusPalette.RestartNeeded, "Requires restart to enable RenderDoc capture."); + Util::Text::RestartNeeded("Requires restart to enable RenderDoc capture."); return; } diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index a9b6b45fdd..331ab70cfd 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -297,6 +297,7 @@ void Upscaling::DrawSettings() const char* presets[] = { "Default", "Preset J", "Preset K", "Preset L", "Preset M" }; ImGui::Combo("DLSS Model Preset", (int*)&settings.presetDLSS, presets, 5); + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::presetDLSS); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Choose which DLSS AI model preset to use."); ImGui::Text("Each model offers different visual quality, performance, and motion stability."); @@ -478,6 +479,7 @@ void Upscaling::DrawSettings() if (ImGui::Combo("Streamline Logging", &logLevelIdx, logLevels, IM_ARRAYSIZE(logLevels))) { settings.streamlineLogLevel = static_cast(logLevelIdx); } + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::streamlineLogLevel); ImGui::TextUnformatted("Changing this requires a restart to take effect."); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Streamline logging controls the verbosity of NVIDIA Streamline backend logs. Useful for debugging issues with DLSS/DLSS-G."); diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index 74e9d0f2ab..ff7299d373 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -81,15 +81,30 @@ struct Upscaling : Feature Settings settings; - inline static constexpr Util::Settings::RestartTable kRestartFields{ { + inline static constexpr Util::Settings::RestartTable kBootSnapshotFields{ { UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), + UTIL_RESTART_FIELD(Settings, streamlineLogLevel, "Streamline Logging"), + UTIL_RESTART_FIELD(Settings, presetDLSS, "DLSS Model Preset"), UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), } }; + Util::Settings::BootSnapshot bootSnapshot{ kBootSnapshotFields }; - Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + inline static constexpr Util::Settings::RestartTable kAlwaysRestartFields{ { + UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), + UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), + UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), + UTIL_RESTART_FIELD(Settings, streamlineLogLevel, "Streamline Logging"), + UTIL_RESTART_FIELD(Settings, presetDLSS, "DLSS Model Preset"), + } }; + inline static constexpr Util::Settings::RestartTable kDlssPerfRestartFields{ { + UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), + UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), + } }; + + mutable std::array restartFieldsRuntime{}; struct JitterCB { @@ -129,11 +144,17 @@ struct Upscaling : Feature // Feature interface overrides std::span GetRestartRequiredFields() const override { - // Frame generation + DLSSperf enable are always restart-gated. - // Method/preset are restart-gated only while DLSSperf's VR hook is active. - const size_t baseCount = 3; - const size_t extra = dlssPerf.IsHookActive() ? 2 : 0; - return { kRestartFields.data(), baseCount + extra }; + if (!dlssPerf.IsHookActive()) { + return { kAlwaysRestartFields.data(), kAlwaysRestartFields.size() }; + } + size_t idx = 0; + for (const auto& field : kAlwaysRestartFields) { + restartFieldsRuntime[idx++] = field; + } + for (const auto& field : kDlssPerfRestartFields) { + restartFieldsRuntime[idx++] = field; + } + return { restartFieldsRuntime.data(), idx }; } const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } const void* GetSettingsBlob() const override { return &settings; } diff --git a/src/Features/Upscaling/Streamline.h b/src/Features/Upscaling/Streamline.h index f173dd1bde..2f1f11e866 100644 --- a/src/Features/Upscaling/Streamline.h +++ b/src/Features/Upscaling/Streamline.h @@ -28,7 +28,6 @@ class Streamline inline std::string GetShortName() { return "Streamline"; } - bool enabledAtBoot = false; bool initialized = false; bool triedInitialization = false; diff --git a/src/Features/VRStereoOptimizations.cpp b/src/Features/VRStereoOptimizations.cpp index 63d5da8942..7a31d46a22 100644 --- a/src/Features/VRStereoOptimizations.cpp +++ b/src/Features/VRStereoOptimizations.cpp @@ -260,9 +260,7 @@ void VRStereoOptimizations::DrawSettings() Util::AddTooltip("Reprojects Eye 0 (left) pixels into Eye 1 (right) using depth and motion data,\nskipping redundant full shading where the views overlap.\nReduces GPU cost in VR by shading each pixel fewer times per frame."); if (globals::game::isVR && settings.stereoMode == StereoMode::Enable && !loaded) { - const auto& themeSettings = Menu::GetSingleton()->GetTheme(); - ImGui::TextColored(themeSettings.StatusPalette.RestartNeeded, - "Restart is required to enable VR stereo reprojection."); + Util::Text::RestartNeeded("Restart is required to enable VR stereo reprojection."); } if (settings.stereoMode == StereoMode::Off) return; diff --git a/src/Features/VolumetricLighting.cpp b/src/Features/VolumetricLighting.cpp index d52d725ee3..e85f1a77e0 100644 --- a/src/Features/VolumetricLighting.cpp +++ b/src/Features/VolumetricLighting.cpp @@ -3,6 +3,7 @@ #include "InteriorSun.h" #include "ShaderCache.h" #include "State.h" +#include "Utils/UI.h" NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( VolumetricLighting::TextureSize, @@ -23,12 +24,16 @@ void VolumetricLighting::DrawSettings() { if (ImGui::Checkbox("Enable Volumetric Lighting in Exteriors", &settings.ExteriorEnabled)) SetupVL(); + if (REL::Module::IsVR()) + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::ExteriorEnabled); if (settings.ExteriorEnabled) DrawVolumetricLightingSettings(settings.ExteriorQuality, settings.ExteriorCustomSize, false, !inInterior); if (ImGui::Checkbox("Enable Volumetric Lighting in Interiors", &settings.InteriorEnabled)) SetupVL(); + if (REL::Module::IsVR()) + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::InteriorEnabled); if (settings.InteriorEnabled) DrawVolumetricLightingSettings(settings.InteriorQuality, settings.InteriorCustomSize, true, inInterior); @@ -156,6 +161,7 @@ void VolumetricLighting::DataLoaded() void VolumetricLighting::PostPostLoad() { + bootSnapshot.LatchIfNeeded(settings); if (REL::Module::IsVR()) { if (settings.ExteriorEnabled || settings.InteriorEnabled) EnableBooleanSettings(hiddenVRSettings, GetName()); @@ -337,4 +343,4 @@ void VolumetricLighting::CopyResource::thunk(ID3D11DeviceContext* a_this, ID3D11 if (!(Util::IsDynamicResolution() && singleton.bEnableVolumetricLighting)) { a_this->CopyResource(a_renderTarget, a_renderTargetSource); } -} \ No newline at end of file +} diff --git a/src/Features/VolumetricLighting.h b/src/Features/VolumetricLighting.h index e5251c52ef..905da66869 100644 --- a/src/Features/VolumetricLighting.h +++ b/src/Features/VolumetricLighting.h @@ -1,5 +1,7 @@ #pragma once +#include "Utils/BootSnapshot.h" + struct VolumetricLighting : Feature { public: @@ -22,7 +24,20 @@ struct VolumetricLighting : Feature Settings settings; - bool enabledAtBoot = false; + inline static constexpr Util::Settings::RestartTable kRestartFields{ { + UTIL_RESTART_FIELD(Settings, ExteriorEnabled, "Volumetric Lighting (Exterior)"), + UTIL_RESTART_FIELD(Settings, InteriorEnabled, "Volumetric Lighting (Interior)"), + } }; + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + + std::span GetRestartRequiredFields() const override + { + // VR-only: enabling VL relies on startup-only game setting initialization. + return REL::Module::IsVR() ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; + } + const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } + const void* GetSettingsBlob() const override { return &settings; } + size_t GetSettingsBlobSize() const override { return sizeof(settings); } virtual inline std::string GetName() override { return "Volumetric Lighting"; } virtual inline std::string GetShortName() override { return "VolumetricLighting"; } diff --git a/src/Features/WeatherEditor.cpp b/src/Features/WeatherEditor.cpp index 8f6ff39e43..12a2fd9d3c 100644 --- a/src/Features/WeatherEditor.cpp +++ b/src/Features/WeatherEditor.cpp @@ -603,7 +603,7 @@ void WeatherEditor::DisplayWindInfo(RE::TESWeather* weather) windRelation = "Left crosswind"; } ImGui::SameLine(); - ImGui::TextColored(theme.StatusPalette.RestartNeeded, "(%s)", windRelation); + Util::Text::RestartNeeded("(%s)", windRelation); if (auto _tt = Util::HoverTooltipWrapper()) { Util::DrawMultiLineTooltip({ "Wind relative to player direction:", diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 7f84372e48..60aecd59a8 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -357,11 +357,7 @@ void EditorWindow::ShowObjectsWindow() activeRecords.resize(4); if (!activeRecords.empty()) { - const auto& theme = Menu::GetSingleton()->GetTheme(); - - ImGui::PushStyleColor(ImGuiCol_Text, theme.StatusPalette.RestartNeeded); - ImGui::Text("Active:"); - ImGui::PopStyleColor(); + Util::Text::RestartNeeded("Active:"); ImGui::SameLine(); const float recordX = ImGui::GetCursorPosX(); From 4c191156f9ad80937201c896066998b3903c1fd9 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 17:09:11 -0700 Subject: [PATCH 05/17] refactor(settings): collapse Upscaling restart tables into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-table split (kBootSnapshotFields + kAlwaysRestartFields + kDlssPerfRestartFields) violated the issue's "single point of truth per field" goal and reintroduced the v2-deferred conditional-discovery mechanism. The per-widget banner gating in DrawSettings already conditions on dlssPerf.IsHookActive() at the call site, so the discovery table doesn't need its own gating — MCP can report all restart-gated fields and clients can check feature state. Drops the mutable runtime concat buffer along with it. Co-Authored-By: Claude Opus 4.7 --- src/Features/Upscaling.h | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index ff7299d373..b5d855fa09 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -81,7 +81,12 @@ struct Upscaling : Feature Settings settings; - inline static constexpr Util::Settings::RestartTable kBootSnapshotFields{ { + // Single source of truth for restart-gated fields. Order is not load-bearing + // — the call-site `DrawSettingDiff` invocations in DrawSettings() handle any + // per-field conditional gating (e.g., qualityMode/upscaleMethod banners only + // render while DLSSperf's render-target hook is active). MCP discovery + // reports the full set; clients can check feature state themselves. + inline static constexpr Util::Settings::RestartTable kRestartFields{ { UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), @@ -90,21 +95,7 @@ struct Upscaling : Feature UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), } }; - Util::Settings::BootSnapshot bootSnapshot{ kBootSnapshotFields }; - - inline static constexpr Util::Settings::RestartTable kAlwaysRestartFields{ { - UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), - UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), - UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), - UTIL_RESTART_FIELD(Settings, streamlineLogLevel, "Streamline Logging"), - UTIL_RESTART_FIELD(Settings, presetDLSS, "DLSS Model Preset"), - } }; - inline static constexpr Util::Settings::RestartTable kDlssPerfRestartFields{ { - UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), - UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), - } }; - - mutable std::array restartFieldsRuntime{}; + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; struct JitterCB { @@ -144,17 +135,7 @@ struct Upscaling : Feature // Feature interface overrides std::span GetRestartRequiredFields() const override { - if (!dlssPerf.IsHookActive()) { - return { kAlwaysRestartFields.data(), kAlwaysRestartFields.size() }; - } - size_t idx = 0; - for (const auto& field : kAlwaysRestartFields) { - restartFieldsRuntime[idx++] = field; - } - for (const auto& field : kDlssPerfRestartFields) { - restartFieldsRuntime[idx++] = field; - } - return { restartFieldsRuntime.data(), idx }; + return { kRestartFields.data(), kRestartFields.size() }; } const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } const void* GetSettingsBlob() const override { return &settings; } From 74377a65798bc6449d5136c521956960bc6c82b0 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 17:31:09 -0700 Subject: [PATCH 06/17] style: clang-format pass on EditorWindow.cpp No behavior change. Pre-commit hook surfaced legacy formatting drift when touching this file in the next commit; landing the format pass separately so the logic diff stays minimal. Co-Authored-By: Claude Opus 4.7 --- src/WeatherEditor/EditorWindow.cpp | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 60aecd59a8..4ee561d8e5 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -260,7 +260,8 @@ void EditorWindow::ShowObjectsWindow() }; // Build active records for the current category tab - struct ActiveRecord { + struct ActiveRecord + { std::string label; std::string suffix; RE::FormID formId; @@ -285,15 +286,17 @@ void EditorWindow::ShowObjectsWindow() }; auto addSingle = [&](RE::TESForm* form, const WidgetVec& widgets, std::string suffix = "") { - if (!form) return; + if (!form) + return; auto id = form->GetFormID(); activeRecords.push_back({ ResolveEditorId(form, widgets), std::move(suffix), id, openByFormId(id, &widgets) }); }; - auto addTOD = [&](auto* (&fields)[RE::TESWeather::ColorTimes::kTotal], const WidgetVec& widgets) { + auto addTOD = [&](auto*(&fields)[RE::TESWeather::ColorTimes::kTotal], const WidgetVec& widgets) { for (int tod = 0; tod < RE::TESWeather::ColorTimes::kTotal; ++tod) { auto* form = fields[tod]; - if (!form) continue; + if (!form) + continue; auto id = form->GetFormID(); bool already = std::any_of(activeRecords.begin(), activeRecords.end(), [&](const ActiveRecord& r) { return r.formId == id; }); @@ -303,7 +306,8 @@ void EditorWindow::ShowObjectsWindow() }; auto addWeather = [&](RE::TESWeather* weatherRecord, std::string suffix = "") { - if (!weatherRecord) return; + if (!weatherRecord) + return; auto id = weatherRecord->GetFormID(); activeRecords.push_back({ ResolveEditorId(weatherRecord, weatherWidgets), std::move(suffix), id, openByFormId(id, &weatherWidgets) }); }; @@ -313,7 +317,8 @@ void EditorWindow::ShowObjectsWindow() if (sky && sky->lastWeather != weather) addWeather(sky->lastWeather, "transitioning"); } else if (m_selectedCategory == "ImageSpace") { - if (weather) addTOD(weather->imageSpaces, imageSpaceWidgets); + if (weather) + addTOD(weather->imageSpaces, imageSpaceWidgets); } else if (m_selectedCategory == "Lighting Template") { auto* player = RE::PlayerCharacter::GetSingleton(); if (player && player->parentCell) @@ -339,13 +344,17 @@ void EditorWindow::ShowObjectsWindow() } }); } } else if (m_selectedCategory == "Volumetric Lighting") { - if (weather) addTOD(weather->volumetricLighting, volumetricLightingWidgets); + if (weather) + addTOD(weather->volumetricLighting, volumetricLightingWidgets); } else if (m_selectedCategory == "Shader Particle Geometry") { - if (weather) addSingle(weather->precipitationData, precipitationWidgets); + if (weather) + addSingle(weather->precipitationData, precipitationWidgets); } else if (m_selectedCategory == "Lens Flare") { - if (weather) addSingle(weather->sunGlareLensFlare, lensFlareWidgets); + if (weather) + addSingle(weather->sunGlareLensFlare, lensFlareWidgets); } else if (m_selectedCategory == "Visual Effect") { - if (weather) addSingle(weather->referenceEffect, referenceEffectWidgets); + if (weather) + addSingle(weather->referenceEffect, referenceEffectWidgets); } // Fall back to current weather when the active category has no active record @@ -1855,8 +1864,8 @@ void EditorWindow::DrawTimeControls() const float framePadX = ImGui::GetStyle().FramePadding.x * 2.0f; const float buttonWidth = std::max({ ImGui::CalcTextSize("Resume Time").x, - ImGui::CalcTextSize("Pause Time").x, - ImGui::CalcTextSize("Reset Speed").x }) + + ImGui::CalcTextSize("Pause Time").x, + ImGui::CalcTextSize("Reset Speed").x }) + framePadX; if (ImGui::Button(timePaused ? "Resume Time" : "Pause Time", ImVec2(buttonWidth, 0))) TogglePause(); From 3bec2ad2037529c6de0235a790f32113c6186657 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 17:31:33 -0700 Subject: [PATCH 07/17] refactor(weather): restore theme local after RestartNeeded migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier TextColored→Util::Text::RestartNeeded conversion left WeatherEditor::DisplayWindInfo with an unused `theme` local (treated as error under /W4) and removed EditorWindow::ShowObjectsWindow's `theme` declaration while leaving a later `theme.Palette.Text` reference dangling. Drop the unused WeatherEditor local; restore the EditorWindow local so the surviving caller compiles. Co-Authored-By: Claude Opus 4.7 --- src/Features/WeatherEditor.cpp | 1 - src/WeatherEditor/EditorWindow.cpp | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Features/WeatherEditor.cpp b/src/Features/WeatherEditor.cpp index 12a2fd9d3c..2272b870a6 100644 --- a/src/Features/WeatherEditor.cpp +++ b/src/Features/WeatherEditor.cpp @@ -559,7 +559,6 @@ void WeatherEditor::DisplayWindInfo(RE::TESWeather* weather) auto sky = globals::game::sky; if (!weather || (weather->data.windSpeed <= 0 && (!sky || sky->windSpeed <= 0.0f))) return; - const auto& theme = Menu::GetSingleton()->GetTheme(); float windSpeedDisplay = weather->data.windSpeed / 255.0f; ImGui::BulletText("Weather Wind Speed: %.2f (raw %d)", windSpeedDisplay, weather->data.windSpeed); if (auto _tt = Util::HoverTooltipWrapper()) { diff --git a/src/WeatherEditor/EditorWindow.cpp b/src/WeatherEditor/EditorWindow.cpp index 4ee561d8e5..9eb12a0fc5 100644 --- a/src/WeatherEditor/EditorWindow.cpp +++ b/src/WeatherEditor/EditorWindow.cpp @@ -366,6 +366,8 @@ void EditorWindow::ShowObjectsWindow() activeRecords.resize(4); if (!activeRecords.empty()) { + const auto& theme = Menu::GetSingleton()->GetTheme(); + Util::Text::RestartNeeded("Active:"); ImGui::SameLine(); const float recordX = ImGui::GetCursorPosX(); From 4aac35901e04f03d9283bbc64db8b50858a01e7d Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 17:50:43 -0700 Subject: [PATCH 08/17] chore(versions): bump DynamicCubemaps/VolumetricLighting/WeatherEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature Version Audit flagged these as needing bumps because the restart-required infrastructure work touched their feature classes. - Dynamic Cubemaps: 2-3-1 → 2-4-0 (BootSnapshot integration) - Volumetric Lighting: 1-1-0 → 1-2-0 (BootSnapshot integration) - Weather Editor: 2-0-1 → 2-1-0 (RestartNeeded helper migration) Co-Authored-By: Claude Opus 4.7 --- features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini | 2 +- .../Volumetric Lighting/Shaders/Features/VolumetricLighting.ini | 2 +- features/Weather Editor/Shaders/Features/WeatherEditor.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini b/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini index 82f2c91940..52d59c5c3a 100644 --- a/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini +++ b/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini @@ -1,5 +1,5 @@ [Info] -Version = 2-3-1 +Version = 2-4-0 [Nexus] autoupload = false diff --git a/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini b/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini index 0bc0292971..9e325f8475 100644 --- a/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini +++ b/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini @@ -1,5 +1,5 @@ [Info] -Version = 1-1-0 +Version = 1-2-0 [Nexus] autoupload = false diff --git a/features/Weather Editor/Shaders/Features/WeatherEditor.ini b/features/Weather Editor/Shaders/Features/WeatherEditor.ini index e9d66d302c..14a4f48fa2 100644 --- a/features/Weather Editor/Shaders/Features/WeatherEditor.ini +++ b/features/Weather Editor/Shaders/Features/WeatherEditor.ini @@ -1,5 +1,5 @@ [Info] -Version = 2-0-1 +Version = 2-1-0 [Nexus] autoupload = false From de44bd5bdff6d6373a55c32010c1923e65c0120c Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 20:03:58 -0700 Subject: [PATCH 09/17] fix(bootsnapshot): address Copilot review - Latch via memcpy so padding bytes copy verbatim. Assignment copies the object representation for trivially-copyable types per the standard, but memcpy removes any compiler latitude and matches the field-slice memcmp comparisons in HasPendingChange. - Test name typo: "starts unlatching" -> "starts unlatched". Co-Authored-By: Claude Opus 4.7 --- src/Utils/BootSnapshot.h | 18 ++++++++++++------ tests/cpp/test_bootsnapshot.cpp | 3 +-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Utils/BootSnapshot.h b/src/Utils/BootSnapshot.h index 133a22e80e..7efad8d870 100644 --- a/src/Utils/BootSnapshot.h +++ b/src/Utils/BootSnapshot.h @@ -13,7 +13,7 @@ namespace Util::Settings namespace detail { template - size_t MemberOffset(T SettingsT::*member) noexcept + size_t MemberOffset(T SettingsT::* member) noexcept { static_assert(std::is_default_constructible_v); SettingsT tmp{}; @@ -37,7 +37,14 @@ namespace Util::Settings void Latch(const SettingsT& live) noexcept { - bootCopy_ = live; + // Byte-wise copy so padding bytes are reproduced verbatim. Assignment + // of a trivially-copyable struct copies the object representation + // (which the C++ standard guarantees for trivially-copyable types), + // but memcpy makes that intent explicit and removes any compiler + // latitude that might leave padding indeterminate — `HasPendingChange` + // uses memcmp on field slices, so any padding-byte drift would + // surface as a false-positive diff. + std::memcpy(&bootCopy_, &live, sizeof(SettingsT)); latched_ = true; } @@ -68,7 +75,7 @@ namespace Util::Settings } template - const T& Boot(T SettingsT::*member) const noexcept + const T& Boot(T SettingsT::* member) const noexcept { static const T kZero{}; if (!latched_) { @@ -79,7 +86,7 @@ namespace Util::Settings } template - bool HasPendingChange(const SettingsT& live, T SettingsT::*member) const noexcept + bool HasPendingChange(const SettingsT& live, T SettingsT::* member) const noexcept { if (!latched_) { return false; @@ -101,7 +108,7 @@ namespace Util::Settings } template - const RestartFieldInfo* FindField(T SettingsT::*member) const noexcept + const RestartFieldInfo* FindField(T SettingsT::* member) const noexcept { const size_t offset = detail::MemberOffset(member); const size_t size = sizeof(T); @@ -120,4 +127,3 @@ namespace Util::Settings bool latched_ = false; }; } - diff --git a/tests/cpp/test_bootsnapshot.cpp b/tests/cpp/test_bootsnapshot.cpp index a2ef9c7373..bc217adb7a 100644 --- a/tests/cpp/test_bootsnapshot.cpp +++ b/tests/cpp/test_bootsnapshot.cpp @@ -21,7 +21,7 @@ namespace } }; } -TEST_CASE("BootSnapshot starts unlatching and ignores diffs", "[bootsnapshot]") +TEST_CASE("BootSnapshot starts unlatched and ignores diffs", "[bootsnapshot]") { Util::Settings::BootSnapshot snap{ kFields }; TestSettings live{}; @@ -61,4 +61,3 @@ TEST_CASE("BootSnapshot exposes field metadata by member", "[bootsnapshot]") REQUIRE(std::string_view(info->jsonKey) == "enabled"); REQUIRE(std::string_view(info->label) == "Enabled"); } - From ab6840e5058d094c6ac2b26ac335444a8e4e9146 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 20:04:07 -0700 Subject: [PATCH 10/17] refactor: extend BootSnapshot to RenderDoc Move loose enableRenderDocCapture/captureFrameCount fields into a proper Settings struct so the boot-snapshot pattern can hook in. Replace the static "Requires restart to enable..." line with the DrawSettingDiff banner. The disable path keeps its performance-impact warning since that's load-bearing context the banner doesn't carry. JSON keys are unchanged ("Enable RenderDoc Capture", "Capture Frame Count"), so existing user configs round-trip cleanly. Co-Authored-By: Claude Opus 4.7 --- src/Features/RenderDoc.cpp | 63 ++++++++++++++++++++------------------ src/Features/RenderDoc.h | 26 ++++++++++++++-- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/Features/RenderDoc.cpp b/src/Features/RenderDoc.cpp index 1759d8794a..9baa3c1568 100644 --- a/src/Features/RenderDoc.cpp +++ b/src/Features/RenderDoc.cpp @@ -31,8 +31,13 @@ RenderDoc* RenderDoc::GetSingleton() void RenderDoc::Load() { + // Latch the boot-time value of restart-gated fields so the menu can + // surface pending diffs even though the renderdoc.dll injection itself + // only runs once per launch. + bootSnapshot.LatchIfNeeded(settings); + // Only load RenderDoc if the user has enabled capture - if (!enableRenderDocCapture) { + if (!settings.enableCapture) { logger::debug("[RenderDoc] RenderDoc capture disabled, skipping initialization"); return; } @@ -132,16 +137,17 @@ void RenderDoc::DrawSettings() bool isSectionVisible = false; // Include enable toggle and annotation forcing logic here - bool prevRenderDocCapture = enableRenderDocCapture; - if (ImGui::Checkbox("Enable RenderDoc Capture", &enableRenderDocCapture)) { - if (enableRenderDocCapture && !prevRenderDocCapture) { + bool prevRenderDocCapture = settings.enableCapture; + if (ImGui::Checkbox("Enable RenderDoc Capture", &settings.enableCapture)) { + if (settings.enableCapture && !prevRenderDocCapture) { globals::state->useFrameAnnotations = globals::state->frameAnnotations; globals::state->frameAnnotations = true; } - if (!enableRenderDocCapture && prevRenderDocCapture) { + if (!settings.enableCapture && prevRenderDocCapture) { globals::state->frameAnnotations = globals::state->useFrameAnnotations; } } + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::enableCapture); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Enable RenderDoc frame capture for providing debug captures to the Open Shaders team (or upstream Community Shaders for upstream-relevant issues)."); @@ -149,18 +155,17 @@ void RenderDoc::DrawSettings() } // The rest of the UI renders only when capture is active - bool renderDocCaptureEnabled = enableRenderDocCapture; + bool renderDocCaptureEnabled = settings.enableCapture; bool renderDocActive = IsAvailable(); const auto& themeSettings = Menu::GetSingleton()->GetTheme(); if (renderDocCaptureEnabled && !renderDocActive) { - Util::Text::RestartNeeded("Requires restart to enable RenderDoc capture."); return; } if (!renderDocCaptureEnabled && renderDocActive) { - ImGui::TextColored(themeSettings.StatusPalette.Warning, "Requires restart to disable RenderDoc capture, performance will be severely impacted."); + ImGui::TextColored(themeSettings.StatusPalette.Warning, "Performance will be severely impacted until the game is restarted."); return; } @@ -539,36 +544,34 @@ void RenderDoc::SetupResources() void RenderDoc::SaveSettings(json& o_json) { - o_json["Enable RenderDoc Capture"] = enableRenderDocCapture; + o_json["Enable RenderDoc Capture"] = settings.enableCapture; o_json["Capture Frame Count"] = GetCaptureFrameCount(); } void RenderDoc::LoadSettings(json& o_json) { if (o_json.contains("Enable RenderDoc Capture") && o_json["Enable RenderDoc Capture"].is_boolean()) { - enableRenderDocCapture = o_json["Enable RenderDoc Capture"]; - } - if (!o_json.contains("Capture Frame Count")) { - return; - } - - const auto& frameCountJson = o_json["Capture Frame Count"]; - if (frameCountJson.is_number_unsigned()) { - const auto frameCount = std::min(frameCountJson.get(), static_cast(kMaxCaptureFrameCount)); - SetCaptureFrameCount(static_cast(frameCount)); - } else if (frameCountJson.is_number_integer()) { - const auto frameCount = std::clamp( - frameCountJson.get(), - static_cast(kMinCaptureFrameCount), - static_cast(kMaxCaptureFrameCount)); - SetCaptureFrameCount(static_cast(frameCount)); + settings.enableCapture = o_json["Enable RenderDoc Capture"]; + } + if (o_json.contains("Capture Frame Count")) { + const auto& frameCountJson = o_json["Capture Frame Count"]; + if (frameCountJson.is_number_unsigned()) { + const auto frameCount = std::min(frameCountJson.get(), static_cast(kMaxCaptureFrameCount)); + SetCaptureFrameCount(static_cast(frameCount)); + } else if (frameCountJson.is_number_integer()) { + const auto frameCount = std::clamp( + frameCountJson.get(), + static_cast(kMinCaptureFrameCount), + static_cast(kMaxCaptureFrameCount)); + SetCaptureFrameCount(static_cast(frameCount)); + } } + bootSnapshot.LatchIfNeeded(settings); } void RenderDoc::RestoreDefaultSettings() { - enableRenderDocCapture = false; - SetCaptureFrameCount(1); + settings = {}; } void RenderDoc::ClearShaderCache() @@ -726,12 +729,12 @@ bool RenderDoc::HandleCaptureHotkey(uint32_t a_vkKey) uint32_t RenderDoc::GetCaptureFrameCount() const { - return std::clamp(captureFrameCount, kMinCaptureFrameCount, kMaxCaptureFrameCount); + return std::clamp(settings.captureFrameCount, kMinCaptureFrameCount, kMaxCaptureFrameCount); } void RenderDoc::SetCaptureFrameCount(uint32_t a_frameCount) { - captureFrameCount = std::clamp(a_frameCount, kMinCaptureFrameCount, kMaxCaptureFrameCount); + settings.captureFrameCount = std::clamp(a_frameCount, kMinCaptureFrameCount, kMaxCaptureFrameCount); } uint64_t RenderDoc::GetRequiredCaptureSpaceBytes() const @@ -780,7 +783,7 @@ bool RenderDoc::IsCapturing() const return false; // RenderDoc API doesn't have a direct IsCapturing method, but we can check if captures are enabled - return enableRenderDocCapture && renderDocApi != nullptr; + return settings.enableCapture && renderDocApi != nullptr; } std::string RenderDoc::GetCapturePath(uint32_t a_index) diff --git a/src/Features/RenderDoc.h b/src/Features/RenderDoc.h index ed3c133392..e5f8da05fb 100644 --- a/src/Features/RenderDoc.h +++ b/src/Features/RenderDoc.h @@ -1,6 +1,7 @@ #pragma once #include "Feature.h" +#include "Utils/BootSnapshot.h" #include #include #include @@ -118,9 +119,28 @@ class RenderDoc : public Feature std::string pendingCaptureComments; mutable std::mutex pendingCommentsMutex; - // RenderDoc capture enable setting - bool enableRenderDocCapture = false; - uint32_t captureFrameCount = 1; + struct Settings + { + bool enableCapture = false; + uint32_t captureFrameCount = 1; + }; + Settings settings; + + // `enableCapture` is restart-gated: the renderdoc.dll only gets injected + // at Load(); toggling the checkbox mid-session stages the change for next + // launch but doesn't install/uninstall the API. + inline static constexpr Util::Settings::RestartTable kRestartFields{ { + UTIL_RESTART_FIELD(Settings, enableCapture, "RenderDoc Capture"), + } }; + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + + std::span GetRestartRequiredFields() const override + { + return { kRestartFields.data(), kRestartFields.size() }; + } + const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } + const void* GetSettingsBlob() const override { return &settings; } + size_t GetSettingsBlobSize() const override { return sizeof(settings); } // Track the last capture count we've processed for automatic comments uint32_t lastCaptureCount = 0; From 2a862c461c846e5ed810130b8ba714bdef60aac4 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 20:04:16 -0700 Subject: [PATCH 11/17] refactor: extend BootSnapshot to VRStereoOptimizations stereoMode is restart-gated (stencil/CS resources are only set up during VR's SetupResources hook). Latch from VR::PostPostLoad and replace the static "Restart is required..." hint with DrawSettingDiff so the user sees the active vs selected values. VRStereoOptimizations isn't itself a Feature subclass (it's a child of the VR feature), so MCP discovery via GetRestartRequiredFields doesn't pick this up. UI behavior still works through the bootSnapshot helper; a follow-up could expose VR's restart-gated sub-fields via the VR Feature's discovery override. Co-Authored-By: Claude Opus 4.7 --- src/Features/VR.cpp | 2 ++ src/Features/VRStereoOptimizations.cpp | 5 ++--- src/Features/VRStereoOptimizations.h | 11 +++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Features/VR.cpp b/src/Features/VR.cpp index 5834a0ace6..1f011fc01b 100644 --- a/src/Features/VR.cpp +++ b/src/Features/VR.cpp @@ -136,6 +136,8 @@ void VR::SetupResources() void VR::PostPostLoad() { + stereoOpt.LatchBootSnapshot(); + gDepthBufferCulling = reinterpret_cast(REL::Offset(0x1EC6B88).address()); if (!gDepthBufferCulling) { static bool s_defaultDepthBufferCulling = false; diff --git a/src/Features/VRStereoOptimizations.cpp b/src/Features/VRStereoOptimizations.cpp index 7a31d46a22..8f230c593b 100644 --- a/src/Features/VRStereoOptimizations.cpp +++ b/src/Features/VRStereoOptimizations.cpp @@ -259,9 +259,8 @@ void VRStereoOptimizations::DrawSettings() settings.stereoMode = static_cast(currentMode); Util::AddTooltip("Reprojects Eye 0 (left) pixels into Eye 1 (right) using depth and motion data,\nskipping redundant full shading where the views overlap.\nReduces GPU cost in VR by shading each pixel fewer times per frame."); - if (globals::game::isVR && settings.stereoMode == StereoMode::Enable && !loaded) { - Util::Text::RestartNeeded("Restart is required to enable VR stereo reprojection."); - } + if (globals::game::isVR) + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::stereoMode); if (settings.stereoMode == StereoMode::Off) return; diff --git a/src/Features/VRStereoOptimizations.h b/src/Features/VRStereoOptimizations.h index 4f324395ce..d3b564eef3 100644 --- a/src/Features/VRStereoOptimizations.h +++ b/src/Features/VRStereoOptimizations.h @@ -3,6 +3,7 @@ #include using json = nlohmann::json; +#include "Utils/BootSnapshot.h" #include #include #include @@ -95,6 +96,16 @@ struct VRStereoOptimizations } settings; + // stereoMode is restart-gated: the stencil/CS resources are only set up + // when `loaded` is true at boot, and toggling mid-session can't install + // them. Latched from VR::PostPostLoad. + inline static constexpr Util::Settings::RestartTable kRestartFields{ { + UTIL_RESTART_FIELD(Settings, stereoMode, "VR Stereo Reprojection"), + } }; + Util::Settings::BootSnapshot bootSnapshot{ kRestartFields }; + + void LatchBootSnapshot() { bootSnapshot.LatchIfNeeded(settings); } + //============================================================================= // GPU CONSTANT BUFFER (must match HLSL cbuffer layout exactly) //============================================================================= From 499506462dbe22f07cd86c5ec2edfbe1f84a74a5 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 20:04:24 -0700 Subject: [PATCH 12/17] refactor(upscaling): drop presetDLSS from restart table; clean redundant hints - presetDLSS is runtime-effective: Streamline::SetDLSSOptions reads settings.presetDLSS per-frame and applies it via slDLSSSetOptions. Treating it as restart-gated was misleading the UI and MCP discovery. (Catch from Copilot review.) - Remove two static "Changing this setting requires a restart" lines next to the DLSS Model Preset and Streamline Logging combos; their pending-diff DrawSettingDiff banners (where applicable) cover the same signal without the duplicate hint. Co-Authored-By: Claude Opus 4.7 --- src/Features/Upscaling.cpp | 3 --- src/Features/Upscaling.h | 6 ++++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Features/Upscaling.cpp b/src/Features/Upscaling.cpp index 331ab70cfd..5569154abc 100644 --- a/src/Features/Upscaling.cpp +++ b/src/Features/Upscaling.cpp @@ -297,12 +297,10 @@ void Upscaling::DrawSettings() const char* presets[] = { "Default", "Preset J", "Preset K", "Preset L", "Preset M" }; ImGui::Combo("DLSS Model Preset", (int*)&settings.presetDLSS, presets, 5); - Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::presetDLSS); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Choose which DLSS AI model preset to use."); ImGui::Text("Each model offers different visual quality, performance, and motion stability."); ImGui::Text("Set to 'Default' for automatic selection based on your Upscale Preset and hardware."); - ImGui::Text("Changing this setting requires a restart to take effect."); } } @@ -480,7 +478,6 @@ void Upscaling::DrawSettings() settings.streamlineLogLevel = static_cast(logLevelIdx); } Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::streamlineLogLevel); - ImGui::TextUnformatted("Changing this requires a restart to take effect."); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Streamline logging controls the verbosity of NVIDIA Streamline backend logs. Useful for debugging issues with DLSS/DLSS-G."); } diff --git a/src/Features/Upscaling.h b/src/Features/Upscaling.h index b5d855fa09..481d491b83 100644 --- a/src/Features/Upscaling.h +++ b/src/Features/Upscaling.h @@ -86,12 +86,14 @@ struct Upscaling : Feature // per-field conditional gating (e.g., qualityMode/upscaleMethod banners only // render while DLSSperf's render-target hook is active). MCP discovery // reports the full set; clients can check feature state themselves. - inline static constexpr Util::Settings::RestartTable kRestartFields{ { + // presetDLSS is deliberately NOT here: Streamline::SetDLSSOptions reads + // settings.presetDLSS per-frame and applies it via slDLSSSetOptions, so + // it's already runtime-effective. + inline static constexpr Util::Settings::RestartTable kRestartFields{ { UTIL_RESTART_FIELD(Settings, frameGenerationMode, "Frame Generation"), UTIL_RESTART_FIELD(Settings, frameGenerationForceEnable, "Force Enable Frame Generation"), UTIL_RESTART_FIELD(Settings, enableDLSSperf, "DLSSperf"), UTIL_RESTART_FIELD(Settings, streamlineLogLevel, "Streamline Logging"), - UTIL_RESTART_FIELD(Settings, presetDLSS, "DLSS Model Preset"), UTIL_RESTART_FIELD(Settings, upscaleMethod, "Upscaling Method"), UTIL_RESTART_FIELD(Settings, qualityMode, "Upscale Preset"), } }; From 20f4142938533d46ea949c8dbc3d05fe628e6a81 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 20:04:32 -0700 Subject: [PATCH 13/17] revert: undo feature .ini version bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is now a refactor with no behavior change for end users — settings shapes are unchanged, JSON round-trips unchanged. Bumping feature versions implies the per-feature settings schema changed, which it didn't. Revert the bumps back to their dev-branch values. - Dynamic Cubemaps: 2-4-0 -> 2-3-1 - Volumetric Lighting: 1-2-0 -> 1-1-0 - Weather Editor: 2-1-0 -> 2-0-1 Co-Authored-By: Claude Opus 4.7 --- features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini | 2 +- .../Volumetric Lighting/Shaders/Features/VolumetricLighting.ini | 2 +- features/Weather Editor/Shaders/Features/WeatherEditor.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini b/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini index 52d59c5c3a..82f2c91940 100644 --- a/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini +++ b/features/Dynamic Cubemaps/Shaders/Features/DynamicCubemaps.ini @@ -1,5 +1,5 @@ [Info] -Version = 2-4-0 +Version = 2-3-1 [Nexus] autoupload = false diff --git a/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini b/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini index 9e325f8475..0bc0292971 100644 --- a/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini +++ b/features/Volumetric Lighting/Shaders/Features/VolumetricLighting.ini @@ -1,5 +1,5 @@ [Info] -Version = 1-2-0 +Version = 1-1-0 [Nexus] autoupload = false diff --git a/features/Weather Editor/Shaders/Features/WeatherEditor.ini b/features/Weather Editor/Shaders/Features/WeatherEditor.ini index 14a4f48fa2..e9d66d302c 100644 --- a/features/Weather Editor/Shaders/Features/WeatherEditor.ini +++ b/features/Weather Editor/Shaders/Features/WeatherEditor.ini @@ -1,5 +1,5 @@ [Info] -Version = 2-1-0 +Version = 2-0-1 [Nexus] autoupload = false From 2ae283690a00d6c6077a305ec4732c689aea0e3b Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 21:01:55 -0700 Subject: [PATCH 14/17] test: use std::string in REQUIRE comparison Catch2 doesn't link the std::string_view StringMaker by default, so `REQUIRE(std::string_view(...) == "literal")` fails to link with LNK2001. Cast to std::string instead. Co-Authored-By: Claude Opus 4.7 --- tests/cpp/test_bootsnapshot.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cpp/test_bootsnapshot.cpp b/tests/cpp/test_bootsnapshot.cpp index bc217adb7a..7c33ea13af 100644 --- a/tests/cpp/test_bootsnapshot.cpp +++ b/tests/cpp/test_bootsnapshot.cpp @@ -58,6 +58,6 @@ TEST_CASE("BootSnapshot exposes field metadata by member", "[bootsnapshot]") Util::Settings::BootSnapshot snap{ kFields }; const auto* info = snap.FindField(&TestSettings::enabled); REQUIRE(info != nullptr); - REQUIRE(std::string_view(info->jsonKey) == "enabled"); - REQUIRE(std::string_view(info->label) == "Enabled"); + REQUIRE(std::string(info->jsonKey) == "enabled"); + REQUIRE(std::string(info->label) == "Enabled"); } From dd972afa4aaeee946868237daef354c096dca9d4 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 25 May 2026 05:13:50 +0000 Subject: [PATCH 15/17] refactor(vr): gate restart-fields via globals::game::isVR Co-authored-by: alandtse <7086117+alandtse@users.noreply.github.com> --- src/Features/DynamicCubemaps.cpp | 25 +++++++++---------------- src/Features/DynamicCubemaps.h | 2 +- src/Features/VolumetricLighting.cpp | 8 ++++---- src/Features/VolumetricLighting.h | 2 +- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/Features/DynamicCubemaps.cpp b/src/Features/DynamicCubemaps.cpp index 215299beac..1d09edfae7 100644 --- a/src/Features/DynamicCubemaps.cpp +++ b/src/Features/DynamicCubemaps.cpp @@ -6,6 +6,7 @@ #include "ShaderCache.h" #include "State.h" #include "Utils/D3D.h" +#include "Utils/UI.h" constexpr auto MIPLEVELS = 8; @@ -31,16 +32,8 @@ void DynamicCubemaps::DrawSettings() if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Enable Screen Space Reflections on Water"); } - if (REL::Module::IsVR() && - bootSnapshot.IsLatched() && - bootSnapshot.HasPendingChange(settings, &Settings::EnabledSSR)) { - const bool active = bootSnapshot.Boot(&Settings::EnabledSSR) != 0; - const bool selected = settings.EnabledSSR != 0; - Util::Text::RestartNeeded( - "Pending restart: Screen Space Reflections changed (active = %s, selected = %s).", - active ? "on" : "off", - selected ? "on" : "off"); - } + if (globals::game::isVR) + Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::EnabledSSR); ImGui::TreePop(); } @@ -122,7 +115,7 @@ void DynamicCubemaps::DrawSettings() } ImGui::TreePop(); } - if (REL::Module::IsVR()) { + if (globals::game::isVR) { if (ImGui::TreeNodeEx("Advanced VR Settings", ImGuiTreeNodeFlags_DefaultOpen)) { Util::RenderImGuiSettingsTree(iniVRCubeMapSettings, "VR"); Util::RenderImGuiSettingsTree(hiddenVRCubeMapSettings, "hiddenVR"); @@ -134,7 +127,7 @@ void DynamicCubemaps::DrawSettings() void DynamicCubemaps::LoadSettings(json& o_json) { settings = o_json; - if (REL::Module::IsVR()) { + if (globals::game::isVR) { Util::LoadGameSettings(iniVRCubeMapSettings); } recompileFlag = true; @@ -143,7 +136,7 @@ void DynamicCubemaps::LoadSettings(json& o_json) void DynamicCubemaps::SaveSettings(json& o_json) { o_json = settings; - if (REL::Module::IsVR()) { + if (globals::game::isVR) { Util::SaveGameSettings(iniVRCubeMapSettings); } } @@ -151,7 +144,7 @@ void DynamicCubemaps::SaveSettings(json& o_json) void DynamicCubemaps::RestoreDefaultSettings() { settings = {}; - if (REL::Module::IsVR()) { + if (globals::game::isVR) { Util::ResetGameSettingsToDefaults(iniVRCubeMapSettings); Util::ResetGameSettingsToDefaults(hiddenVRCubeMapSettings); } @@ -160,7 +153,7 @@ void DynamicCubemaps::RestoreDefaultSettings() void DynamicCubemaps::DataLoaded() { - if (REL::Module::IsVR()) { + if (globals::game::isVR) { // enable cubemap settings in VR Util::EnableBooleanSettings(iniVRCubeMapSettings, GetName()); Util::EnableBooleanSettings(hiddenVRCubeMapSettings, GetName()); @@ -171,7 +164,7 @@ void DynamicCubemaps::DataLoaded() void DynamicCubemaps::PostPostLoad() { bootSnapshot.LatchIfNeeded(settings); - if (REL::Module::IsVR() && settings.EnabledSSR) { + if (globals::game::isVR && settings.EnabledSSR) { std::map earlyhiddenVRCubeMapSettings{ { "bScreenSpaceReflectionEnabled:Display", 0x1ED5BC0 }, }; diff --git a/src/Features/DynamicCubemaps.h b/src/Features/DynamicCubemaps.h index f4913a3b8c..ac46d30629 100644 --- a/src/Features/DynamicCubemaps.h +++ b/src/Features/DynamicCubemaps.h @@ -129,7 +129,7 @@ struct DynamicCubemaps : Feature std::span GetRestartRequiredFields() const override { // VR-only: enabling SSR needs game-setting initialization at startup. - return REL::Module::IsVR() ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; + return globals::game::isVR ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; } const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } const void* GetSettingsBlob() const override { return &settings; } diff --git a/src/Features/VolumetricLighting.cpp b/src/Features/VolumetricLighting.cpp index e85f1a77e0..c7f9739194 100644 --- a/src/Features/VolumetricLighting.cpp +++ b/src/Features/VolumetricLighting.cpp @@ -24,7 +24,7 @@ void VolumetricLighting::DrawSettings() { if (ImGui::Checkbox("Enable Volumetric Lighting in Exteriors", &settings.ExteriorEnabled)) SetupVL(); - if (REL::Module::IsVR()) + if (globals::game::isVR) Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::ExteriorEnabled); if (settings.ExteriorEnabled) @@ -32,7 +32,7 @@ void VolumetricLighting::DrawSettings() if (ImGui::Checkbox("Enable Volumetric Lighting in Interiors", &settings.InteriorEnabled)) SetupVL(); - if (REL::Module::IsVR()) + if (globals::game::isVR) Util::UI::DrawSettingDiff(bootSnapshot, settings, &Settings::InteriorEnabled); if (settings.InteriorEnabled) @@ -152,7 +152,7 @@ void VolumetricLighting::DataLoaded() const static auto address = REL::Offset{ 0x1ec6b88 }.address(); bool& bDepthBufferCulling = *reinterpret_cast(address); - if (REL::Module::IsVR() && bDepthBufferCulling && shaderCache->IsDiskCache()) { + if (globals::game::isVR && bDepthBufferCulling && shaderCache->IsDiskCache()) { // clear cache to fix bug caused by bDepthBufferCulling logger::info("Force clearing cache due to bDepthBufferCulling"); shaderCache->Clear(); @@ -162,7 +162,7 @@ void VolumetricLighting::DataLoaded() void VolumetricLighting::PostPostLoad() { bootSnapshot.LatchIfNeeded(settings); - if (REL::Module::IsVR()) { + if (globals::game::isVR) { if (settings.ExteriorEnabled || settings.InteriorEnabled) EnableBooleanSettings(hiddenVRSettings, GetName()); auto address = REL::RelocationID(100475, 0).address() + 0x45b; // AE not needed, VR only hook diff --git a/src/Features/VolumetricLighting.h b/src/Features/VolumetricLighting.h index 905da66869..4c9c7bbc15 100644 --- a/src/Features/VolumetricLighting.h +++ b/src/Features/VolumetricLighting.h @@ -33,7 +33,7 @@ struct VolumetricLighting : Feature std::span GetRestartRequiredFields() const override { // VR-only: enabling VL relies on startup-only game setting initialization. - return REL::Module::IsVR() ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; + return globals::game::isVR ? std::span{ kRestartFields.data(), kRestartFields.size() } : std::span{}; } const void* GetBootValue(std::string_view jsonKey) const override { return bootSnapshot.RawBoot(jsonKey); } const void* GetSettingsBlob() const override { return &settings; } From cf77bed84aed4868d1eff3203e21f53e15920851 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 24 May 2026 22:18:36 -0700 Subject: [PATCH 16/17] refactor(mcp): consolidate restart-required actions into list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the minimal-but-semantically-rich design philosophy (EdenLabs/agentic-renderdoc), drop the two new `list_restart_required` and `list_pending_restart` actions and fold their information into the existing `list` response. Each feature entry now carries an optional `restartFields` array of `{ key, label, pending }` — `pending=true` means the user has staged a change that won't apply until next launch. One tool call answers "what features exist", "which fields are restart-gated", and "what's currently pending"; clients filter as needed. Drops the BytesToHex helper that the old list_pending_restart used to emit raw boot/live bytes. Clients that need typed values for diffing can use `get` to read live settings; the staged value is right there. Co-Authored-By: Claude Opus 4.7 --- src/Features/RemoteControl.cpp | 150 ++++++++------------------------- 1 file changed, 36 insertions(+), 114 deletions(-) diff --git a/src/Features/RemoteControl.cpp b/src/Features/RemoteControl.cpp index a6c259f30c..186c926345 100644 --- a/src/Features/RemoteControl.cpp +++ b/src/Features/RemoteControl.cpp @@ -452,7 +452,7 @@ static mcp::json EngineStateBlob() // Helper used by feature(action="list") to build one entry per feature. static mcp::json FeatureEntry(Feature* f) { - return mcp::json({ + mcp::json entry({ { "name", f->GetName() }, { "shortName", f->GetShortName() }, { "loaded", f->loaded }, @@ -462,19 +462,36 @@ static mcp::json FeatureEntry(Feature* f) { "supportsVR", f->SupportsVR() }, { "inMenu", f->IsInMenu() }, }); -} -static std::string BytesToHex(const void* data, size_t size) -{ - static constexpr char kHex[] = "0123456789abcdef"; - std::string out; - out.reserve(size * 2); - const auto* bytes = reinterpret_cast(data); - for (size_t i = 0; i < size; ++i) { - out.push_back(kHex[(bytes[i] >> 4) & 0xF]); - out.push_back(kHex[bytes[i] & 0xF]); + // Inline restart-gated metadata so `list` is the single tool that answers + // "what features exist", "which fields need a restart to apply", and + // "is anything currently pending". Each entry's `pending` is true when + // the live setting differs from the boot-latched value. + const auto fields = f->GetRestartRequiredFields(); + if (!fields.empty()) { + mcp::json restartFields = mcp::json::array(); + const auto* liveBase = reinterpret_cast(f->GetSettingsBlob()); + const size_t liveSize = f->GetSettingsBlobSize(); + for (const auto& field : fields) { + bool pending = false; + if (liveBase && field.jsonKey && field.size != 0 && + field.offset + field.size <= liveSize) { + const void* boot = f->GetBootValue(field.jsonKey); + if (boot && + std::memcmp(boot, liveBase + field.offset, field.size) != 0) { + pending = true; + } + } + restartFields.push_back(mcp::json({ + { "key", field.jsonKey ? field.jsonKey : "" }, + { "label", field.label ? field.label : "" }, + { "pending", pending }, + })); + } + entry["restartFields"] = restartFields; } - return out; + + return entry; } void RemoteControl::RegisterInspectTool() @@ -531,15 +548,11 @@ void RemoteControl::RegisterFeatureTool() "Actions:\n" " list — no other params. Returns a JSON array; " "each entry has { name, shortName, loaded, version, " - "category, isCore, supportsVR, inMenu }.\n" - " list_restart_required — params: shortName (optional). " - "Returns a JSON array of restart-gated setting keys. " - "If shortName is omitted, returns entries for all " - "features.\n" - " list_pending_restart — params: shortName (optional). " - "Returns a JSON array of restart-gated keys whose " - "live value differs from the boot-latched value. " - "Entries include boot/live bytes as hex.\n" + "category, isCore, supportsVR, inMenu }. Features " + "with restart-gated settings also include " + "`restartFields: [{ key, label, pending }]` — " + "`pending=true` means the user has staged a change " + "that won't take effect until the next launch.\n" " get — params: shortName. Returns the " "Feature::SaveSettings(json) blob. May return null " "if the feature has no SaveSettings/LoadSettings " @@ -579,8 +592,7 @@ void RemoteControl::RegisterFeatureTool() "has a hook that isn't gated on `loaded` — file an " "issue with the shortName.") .with_string_param("action", - "One of: 'list', 'list_restart_required', " - "'list_pending_restart', 'get', 'set', 'reset', 'toggle'.") + "One of: 'list', 'get', 'set', 'reset', 'toggle'.") .with_string_param("shortName", "Required for all actions except 'list'. From the " "list response.", @@ -612,96 +624,6 @@ void RemoteControl::RegisterFeatureTool() const std::string shortName = params.value("shortName", std::string{}); - const auto findFeatureAnyState = [&](const std::string& name) -> Feature* { - for (auto* f : Feature::GetFeatureList()) { - if (f->GetShortName() == name) { - return f; - } - } - return nullptr; - }; - - if (action == "list_restart_required") { - mcp::json out = mcp::json::array(); - if (!shortName.empty()) { - auto* f = findFeatureAnyState(shortName); - if (!f) { - return ErrorResult("feature not found", { { "shortName", shortName } }); - } - for (const auto& field : f->GetRestartRequiredFields()) { - out.push_back(mcp::json({ - { "feature", f->GetShortName() }, - { "key", field.jsonKey }, - { "label", field.label }, - })); - } - return TextResult(out.dump()); - } - - for (auto* f : Feature::GetFeatureList()) { - for (const auto& field : f->GetRestartRequiredFields()) { - out.push_back(mcp::json({ - { "feature", f->GetShortName() }, - { "key", field.jsonKey }, - { "label", field.label }, - })); - } - } - return TextResult(out.dump()); - } - - if (action == "list_pending_restart") { - mcp::json out = mcp::json::array(); - const auto emitPending = [&](Feature* f) { - const auto fields = f->GetRestartRequiredFields(); - if (fields.empty()) { - return; - } - const auto* liveBase = reinterpret_cast(f->GetSettingsBlob()); - const size_t liveSize = f->GetSettingsBlobSize(); - if (!liveBase || liveSize == 0) { - return; - } - for (const auto& field : fields) { - if (!field.jsonKey || field.size == 0) { - continue; - } - if (field.offset + field.size > liveSize) { - continue; - } - const void* boot = f->GetBootValue(field.jsonKey); - if (!boot) { - continue; - } - const void* live = liveBase + field.offset; - if (std::memcmp(boot, live, field.size) != 0) { - out.push_back(mcp::json({ - { "feature", f->GetShortName() }, - { "key", field.jsonKey }, - { "label", field.label }, - { "size", field.size }, - { "boot_hex", BytesToHex(boot, field.size) }, - { "live_hex", BytesToHex(live, field.size) }, - })); - } - } - }; - - if (!shortName.empty()) { - auto* f = Feature::FindFeatureByShortName(shortName); - if (!f) { - return ErrorResult("feature not found or not loaded", { { "shortName", shortName } }); - } - emitPending(f); - return TextResult(out.dump()); - } - - Feature::ForEachLoadedFeature("RemoteControlPendingRestart", [&](Feature* f) { - emitPending(f); - }); - return TextResult(out.dump()); - } - if (shortName.empty()) { return ErrorResult("missing required parameter 'shortName'", { { "action", action } }); @@ -831,7 +753,7 @@ void RemoteControl::RegisterFeatureTool() return ErrorResult("unknown action", { { "action", action }, - { "supported", mcp::json::array({ "list", "list_restart_required", "list_pending_restart", "get", "set", "reset", "toggle" }) } }); + { "supported", mcp::json::array({ "list", "get", "set", "reset", "toggle" }) } }); }); } From a07e631f61b6a7fa706eddf110781b01aa0aec8d Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 25 May 2026 00:50:30 -0700 Subject: [PATCH 17/17] refactor: tint feature list green when restart-gated setting is pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a loaded feature has a restart-gated setting whose live value differs from the boot-latched value, paint its name in the feature list with the same StatusPalette.RestartNeeded green that already signals "INI exists but feature not loaded — toggled off at boot." Same color, same meaning from the user's POV: "this feature has unmade changes that take effect on restart." Adds `Feature::HasAnyPendingRestart()` for the per-frame check — short-circuits to false for features with no restart-gated fields, so the per-frame cost is one virtual call + an empty-span check for the vast majority of features. Features with restart-gated fields walk their table doing field-slice memcmp, same data path the MCP `list` response uses for its per-field `pending` flag. Co-Authored-By: Claude Opus 4.7 --- src/Feature.h | 25 +++++++++++++++++++++++++ src/Menu/FeatureListRenderer.cpp | 6 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Feature.h b/src/Feature.h index c6aeaeaf43..e8e75a0afe 100644 --- a/src/Feature.h +++ b/src/Feature.h @@ -5,6 +5,7 @@ #include "FeatureVersions.h" #include "Utils/RestartSettings.h" +#include #include #include #ifdef TRACY_ENABLE @@ -33,6 +34,30 @@ struct Feature virtual const void* GetSettingsBlob() const { return nullptr; } virtual size_t GetSettingsBlobSize() const { return 0; } + // True if any restart-gated setting's live value differs from the + // boot-latched value. Drives the green "RestartNeeded" tint in the + // feature list and the `pending` flag in MCP's `list` response. + bool HasAnyPendingRestart() const + { + const auto fields = GetRestartRequiredFields(); + if (fields.empty()) + return false; + const auto* live = reinterpret_cast(GetSettingsBlob()); + const size_t liveSize = GetSettingsBlobSize(); + if (!live || liveSize == 0) + return false; + for (const auto& field : fields) { + if (!field.jsonKey || field.size == 0) + continue; + if (field.offset + field.size > liveSize) + continue; + const void* boot = GetBootValue(field.jsonKey); + if (boot && std::memcmp(boot, live + field.offset, field.size) != 0) + return true; + } + return false; + } + // Nexus Mods base URL for Skyrim Special Edition static constexpr std::string_view NEXUS_BASE_URL = "https://www.nexusmods.com/skyrimspecialedition/mods/"; bool loaded = false; diff --git a/src/Menu/FeatureListRenderer.cpp b/src/Menu/FeatureListRenderer.cpp index 978984eb7d..c8f66ffef0 100644 --- a/src/Menu/FeatureListRenderer.cpp +++ b/src/Menu/FeatureListRenderer.cpp @@ -509,7 +509,11 @@ void FeatureListRenderer::ListMenuVisitor::operator()(Feature* feat) if (isDisabled) { textColor = themeSettings.StatusPalette.Disable; } else if (isLoaded) { - textColor = ImGui::GetStyleColorVec4(ImGuiCol_Text); + // Loaded feature with staged but-not-yet-applied restart-gated + // settings tints the same green as a feature pending re-enable. + // Same semantic from the user's POV: "this feature has unmade + // changes that take effect on restart." + textColor = feat->HasAnyPendingRestart() ? themeSettings.StatusPalette.RestartNeeded : ImGui::GetStyleColorVec4(ImGuiCol_Text); } else if (hasFailedMessage) { textColor = feat->version.empty() ? themeSettings.StatusPalette.Disable : themeSettings.StatusPalette.Error; } else {