diff --git a/src/Features/PerformanceOverlay.cpp b/src/Features/PerformanceOverlay.cpp index 590e4e0cbb..a7c9cc8145 100644 --- a/src/Features/PerformanceOverlay.cpp +++ b/src/Features/PerformanceOverlay.cpp @@ -1195,6 +1195,10 @@ void PerformanceOverlay::DrawABTestSection(const std::vector& allRo this->settingsDiff.clear(); this->settingsDiffLoaded = false; showSettingsDiff = false; + // Also clear cached snapshots so diff does not linger after user opts to clear + if (abTestingManager) { + abTestingManager->ClearCachedSnapshots(); + } ImGui::EndGroup(); ImGui::Separator(); return; @@ -1203,9 +1207,15 @@ void PerformanceOverlay::DrawABTestSection(const std::vector& allRo // --- Settings diff section (inline, toggled) --- if (showSettingsDiff) { if (!this->settingsDiffLoaded) { - std::filesystem::path userPath = Util::PathHelpers::GetDataPath() / "SKSE/Plugins/CommunityShaders/SettingsUser.json"; - std::filesystem::path testPath = Util::PathHelpers::GetDataPath() / "SKSE/Plugins/CommunityShaders/SettingsTest.json"; - this->settingsDiff = Util::FileSystem::LoadJsonDiff(userPath, testPath); + // Use cached memory data from ABTestingManager instead of loading from disk + if (abTestingManager && abTestingManager->HasCachedSnapshots()) { + // Pull structured diff entries directly from ABTestingManager (in-memory snapshots) + this->settingsDiff = abTestingManager->GetConfigDiffEntries(); + } else { + std::filesystem::path userPath = Util::PathHelpers::GetDataPath() / "SKSE/Plugins/CommunityShaders/SettingsUser.json"; + std::filesystem::path testPath = Util::PathHelpers::GetDataPath() / "SKSE/Plugins/CommunityShaders/SettingsTest.json"; + this->settingsDiff = Util::FileSystem::LoadJsonDiff(userPath, testPath); + } this->settingsDiffLoaded = true; } ImGui::TextUnformatted("Differences between USER (A) and TEST (B) configs:"); diff --git a/src/Features/PerformanceOverlay/ABTesting/ABTesting.cpp b/src/Features/PerformanceOverlay/ABTesting/ABTesting.cpp index 14192319df..d257c856cf 100644 --- a/src/Features/PerformanceOverlay/ABTesting/ABTesting.cpp +++ b/src/Features/PerformanceOverlay/ABTesting/ABTesting.cpp @@ -2,8 +2,11 @@ #include "Features/PerformanceOverlay.h" #include "Menu.h" #include "State.h" +#include "Utils/FileSystem.h" #include "Utils/UI.h" +#include #include +#include #include ABTestingManager* ABTestingManager::GetSingleton() @@ -23,8 +26,26 @@ void ABTestingManager::Enable() auto* state = globals::state; auto& performanceOverlay = globals::features::performanceOverlay; - logger::info("Saving current settings for Variant B (TEST) and starting test with interval {}.", testInterval); - state->Save(State::ConfigMode::TEST); + logger::info("Starting A/B testing with current settings as Variant B (TEST), interval {} seconds.", testInterval); + + // Preserve overlay enabled state before config operations + bool overlayWasEnabled = performanceOverlay.settings.ShowInOverlay; + + // Save current settings as TEST variant (Variant B) in memory + testConfigSnapshot = nlohmann::json::object(); + state->SaveToJson(testConfigSnapshot); + hasTestSnapshot = true; + + // Load and cache USER settings in memory (to avoid disk I/O during swaps) + userConfigSnapshot = nlohmann::json::object(); + state->Load(State::ConfigMode::USER); + state->SaveToJson(userConfigSnapshot); + hasUserSnapshot = true; + + // Load TEST variant to start with (user's configured test settings) + state->LoadFromJson(testConfigSnapshot); + usingTestConfig = true; + abTestingEnabled = true; // Initialize QueryPerformanceCounter timing @@ -33,9 +54,10 @@ void ABTestingManager::Enable() } QueryPerformanceCounter(&lastTestSwitch); - // Preserve overlay enabled state - bool overlayWasEnabled = performanceOverlay.settings.ShowInOverlay; + // Restore overlay enabled state after config operations performanceOverlay.settings.ShowInOverlay = overlayWasEnabled; + + logger::info("A/B Testing enabled - starting with Variant B (TEST). Both variants cached in memory for unbiased swapping."); } } @@ -45,12 +67,20 @@ void ABTestingManager::Disable() auto* state = globals::state; auto& performanceOverlay = globals::features::performanceOverlay; - logger::info("Disabling A/B testing. Will restore to Variant B (TEST) config."); - state->Load(State::ConfigMode::TEST); // restore last settings before entering test mode - abTestingEnabled = false; - // Preserve overlay enabled state bool overlayWasEnabled = performanceOverlay.settings.ShowInOverlay; + + logger::info("Disabling A/B testing. Restoring to Variant B (TEST) config from memory."); + + // Restore TEST config from memory snapshot (no disk read) + if (hasTestSnapshot) { + state->LoadFromJson(testConfigSnapshot); + } else { + logger::warn("No TEST snapshot available, staying with current config."); + } + + abTestingEnabled = false; + performanceOverlay.settings.ShowInOverlay = overlayWasEnabled; } } @@ -63,7 +93,7 @@ void ABTestingManager::Update() auto* state = globals::state; auto& performanceOverlay = globals::features::performanceOverlay; - // Preserve overlay enabled state when switching configs + // Check if it's time to swap LARGE_INTEGER currentTime; QueryPerformanceCounter(¤tTime); float seconds = (currentTime.QuadPart - lastTestSwitch.QuadPart) / static_cast(timingFrequency.QuadPart); @@ -71,12 +101,31 @@ void ABTestingManager::Update() if (remaining < 0.0f) { bool overlayWasEnabled = performanceOverlay.settings.ShowInOverlay; + + // Swap between variants usingTestConfig = !usingTestConfig; - logger::info("Swapping to {} (A/B Test): {}", - usingTestConfig ? "Variant B (TEST)" : "Variant A (USER)", - usingTestConfig ? "TEST config" : "USER config"); - state->Load(usingTestConfig ? State::ConfigMode::TEST : State::ConfigMode::USER); - performanceOverlay.settings.ShowInOverlay = overlayWasEnabled; // Restore overlay state + logger::info("A/B Test swap to {} (from memory snapshot)", + usingTestConfig ? "Variant B (TEST)" : "Variant A (USER)"); + + if (usingTestConfig) { + // Swap to TEST - load from memory snapshot (no disk I/O) + if (hasTestSnapshot) { + state->LoadFromJson(testConfigSnapshot); + } else { + logger::error("TEST snapshot missing! Cannot swap to Variant B."); + usingTestConfig = false; // Stay on USER + } + } else { + // Swap to USER - load from memory snapshot (no disk I/O) + if (hasUserSnapshot) { + state->LoadFromJson(userConfigSnapshot); + } else { + logger::error("USER snapshot missing! Cannot swap to Variant A."); + usingTestConfig = true; // Stay on TEST + } + } + + performanceOverlay.settings.ShowInOverlay = overlayWasEnabled; QueryPerformanceCounter(&lastTestSwitch); // Notify the A/B test aggregator of the variant switch @@ -102,14 +151,80 @@ void ABTestingManager::DrawSettingsUI() if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text( - "Sets number of seconds before toggling between Variant A (USER) and Variant B (TEST) config for A/B testing. " - "0 disables. Non-zero will enable A/B testing mode. " - "Enabling will save current settings as TEST config (Variant B). " - "This has no impact if no settings are changed. " - "Variant A = USER config, Variant B = TEST config."); + "A/B Testing compares two configurations by automatically swapping between them.\n" + "Workflow: Configure your test settings, then enable A/B testing.\n" + "- Variant B (TEST) = Your current settings when you enable testing\n" + "- Variant A (USER) = Your previously saved user configuration\n" + "Testing starts with Variant B, then swaps every N seconds.\n" + "Set to 0 to disable and restore TEST settings."); } } +std::vector ABTestingManager::GetConfigDifferencesForDisplay() const +{ + std::vector differences; + + if (!hasTestSnapshot || !hasUserSnapshot) + return differences; + + auto diffEntries = Util::FileSystem::DiffJson(userConfigSnapshot, testConfigSnapshot, 0.0001f); + + // Format diff entries for display + for (const auto& entry : diffEntries) { + std::string path = entry.path; + std::string aVal = entry.aValue; + std::string bVal = entry.bValue; + + // Clean up JSON path (remove leading slash and simplify) + if (!path.empty() && path[0] == '/') { + path = path.substr(1); + // Replace slashes with dots for readability + std::replace(path.begin(), path.end(), '/', '.'); + } + + // Skip version changes (not relevant to user) + if (path == "Version") + continue; + + // Truncate long values + if (aVal.length() > 30) + aVal = aVal.substr(0, 27) + "..."; + if (bVal.length() > 30) + bVal = bVal.substr(0, 27) + "..."; + + // Format: "path: oldValue -> newValue" + differences.push_back(fmt::format("{}: {} -> {}", path, aVal, bVal)); + } + + return differences; +} + +std::vector ABTestingManager::GetConfigDiffEntries(float epsilon) const +{ + if (!hasTestSnapshot || !hasUserSnapshot) + return {}; + + return Util::FileSystem::DiffJson(userConfigSnapshot, testConfigSnapshot, epsilon); +} + +void ABTestingManager::ClearCachedSnapshots() +{ + try { + testConfigSnapshot.clear(); + userConfigSnapshot.clear(); + hasTestSnapshot = false; + hasUserSnapshot = false; + } catch (...) { + // No-op if clear fails + } +} + +std::vector ABTestingManager::GetConfigDifferences() const +{ + // Keep this for DrawOverlayUI to avoid circular dependencies + return GetConfigDifferencesForDisplay(); +} + void ABTestingManager::DrawOverlayUI() { if (!abTestingEnabled) @@ -120,16 +235,40 @@ void ABTestingManager::DrawOverlayUI() float seconds = (currentTime.QuadPart - lastTestSwitch.QuadPart) / static_cast(timingFrequency.QuadPart); auto remaining = static_cast(testInterval) - seconds; - ImGui::SetNextWindowBgAlpha(1.0f); + // Match CS menu background alpha (0.85f from FullPalette[ImGuiCol_ChildBg]) + ImGui::SetNextWindowBgAlpha(0.85f); ImGui::SetNextWindowPos(ImVec2(10, 10)); - if (!ImGui::Begin("Testing", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) { + if (!ImGui::Begin("A/B Testing", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings)) { ImGui::End(); return; } remaining = std::max(0.0f, remaining); - ImGui::Text(fmt::format("{} : {:.1f} seconds left", + + // Show current variant and time + ImGui::Text(fmt::format("{} : {:.1f}s left", usingTestConfig ? "Variant B (TEST)" : "Variant A (USER)", remaining) .c_str()); + + // Show what changed (for both variants) + if (hasTestSnapshot) { + auto differences = GetConfigDifferences(); + + if (!differences.empty()) { + ImGui::Separator(); + + constexpr size_t MAX_CHANGES_DISPLAYED = 10; // Show max 10 individual changes, otherwise show count + if (differences.size() <= MAX_CHANGES_DISPLAYED) { + ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f), "Changes from USER:"); + for (const auto& diff : differences) { + ImGui::BulletText("%s", diff.c_str()); + } + } else { + ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f), + "%zu settings changed", differences.size()); + } + } + } + ImGui::End(); } \ No newline at end of file diff --git a/src/Features/PerformanceOverlay/ABTesting/ABTesting.h b/src/Features/PerformanceOverlay/ABTesting/ABTesting.h index 64160dfe05..5dc6df174c 100644 --- a/src/Features/PerformanceOverlay/ABTesting/ABTesting.h +++ b/src/Features/PerformanceOverlay/ABTesting/ABTesting.h @@ -1,5 +1,6 @@ #pragma once #include "ABTestAggregator.h" +#include "Utils/FileSystem.h" #include #include @@ -27,6 +28,16 @@ class ABTestingManager // Access to aggregator ABTestAggregator& GetAggregator() { return aggregator; } + // Access to cached settings for performance overlay (in-memory, no disk I/O) + bool HasCachedSnapshots() const { return hasTestSnapshot && hasUserSnapshot; } + const nlohmann::json& GetUserSnapshot() const { return userConfigSnapshot; } + const nlohmann::json& GetTestSnapshot() const { return testConfigSnapshot; } + std::vector GetConfigDifferencesForDisplay() const; + std::vector GetConfigDiffEntries(float epsilon = 0.0001f) const; + + // Clear cached snapshots explicitly (used when overlay results are cleared) + void ClearCachedSnapshots(); + private: uint32_t testInterval = 0; bool abTestingEnabled = false; @@ -34,4 +45,13 @@ class ABTestingManager LARGE_INTEGER timingFrequency = { 0 }; LARGE_INTEGER lastTestSwitch = { 0 }; ABTestAggregator aggregator; + + // In-memory storage for both variants to avoid disk I/O during swapping + nlohmann::json testConfigSnapshot; + nlohmann::json userConfigSnapshot; + bool hasTestSnapshot = false; + bool hasUserSnapshot = false; + + // Track what changed between USER and TEST configs + std::vector GetConfigDifferences() const; }; \ No newline at end of file diff --git a/src/Menu/AdvancedSettingsRenderer.cpp b/src/Menu/AdvancedSettingsRenderer.cpp index 5236697c84..4cec6b3a4c 100644 --- a/src/Menu/AdvancedSettingsRenderer.cpp +++ b/src/Menu/AdvancedSettingsRenderer.cpp @@ -59,7 +59,6 @@ void AdvancedSettingsRenderer::RenderAdvancedSettings( ImGui::EndChild(); ImGui::EndTabItem(); } - // Shader Debug Tab if (MenuFonts::BeginTabItemWithFont("Shader Debug", Menu::FontRole::Subheading)) { if (ImGui::BeginChild("##ShaderDebugContent", ImVec2(0, 0), false)) { @@ -69,6 +68,15 @@ void AdvancedSettingsRenderer::RenderAdvancedSettings( ImGui::EndTabItem(); } + // Testing Tab (for A/B Testing and related settings) + if (MenuFonts::BeginTabItemWithFont("Testing", Menu::FontRole::Subheading)) { + if (ImGui::BeginChild("##Testing", ImVec2(0, 0), false)) { + RenderTestingSection(); + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } } @@ -129,10 +137,6 @@ void AdvancedSettingsRenderer::RenderLoggingSection() "The more threads the faster compilation will finish but may make the system unresponsive. "); } - // A/B Testing settings - auto* abTestingManager = ABTestingManager::GetSingleton(); - abTestingManager->DrawSettingsUI(); - // Dump Ini Settings button if (ImGui::Button("Dump Ini Settings", { -1, 0 })) { Util::DumpSettingsOptions(); @@ -565,3 +569,10 @@ void AdvancedSettingsRenderer::RenderDeveloperSection() } } } + +void AdvancedSettingsRenderer::RenderTestingSection() +{ + // A/B Testing settings + auto* abTestingManager = ABTestingManager::GetSingleton(); + abTestingManager->DrawSettingsUI(); +} diff --git a/src/Menu/AdvancedSettingsRenderer.h b/src/Menu/AdvancedSettingsRenderer.h index 7a1cefca2c..3d466e2a09 100644 --- a/src/Menu/AdvancedSettingsRenderer.h +++ b/src/Menu/AdvancedSettingsRenderer.h @@ -19,4 +19,5 @@ class AdvancedSettingsRenderer static void RenderPBRSection(const std::function& drawTruePBRSettings); static void RenderDisableAtBootSection(const std::function& drawDisableAtBootSettings); static void RenderDeveloperSection(); + static void RenderTestingSection(); }; \ No newline at end of file diff --git a/src/State.cpp b/src/State.cpp index 29792bb533..5dd354c9b3 100644 --- a/src/State.cpp +++ b/src/State.cpp @@ -181,7 +181,6 @@ static std::string GetConfigPath(State::ConfigMode a_configMode) void State::Load(ConfigMode a_configMode, bool a_allowReload) { - auto shaderCache = globals::shaderCache; json settings; bool errorDetected = false; @@ -275,58 +274,9 @@ void State::Load(ConfigMode a_configMode, bool a_allowReload) } try { - // Load Menu settings - - if (settings["Menu"].is_object()) { - logger::info("Loading 'Menu' settings"); - globals::menu->Load(settings["Menu"]); - } - - if (settings["Advanced"].is_object()) { - logger::info("Loading 'Advanced' settings"); - json& advanced = settings["Advanced"]; - if (advanced["Dump Shaders"].is_boolean()) - shaderCache->SetDump(advanced["Dump Shaders"]); - if (advanced["Log Level"].is_number_integer()) - logLevel = magic_enum::enum_cast(advanced["Log Level"].get()).value_or(spdlog::level::info); - if (advanced["Shader Defines"].is_string()) - SetDefines(advanced["Shader Defines"]); - if (advanced["Compiler Threads"].is_number_integer()) - shaderCache->compilationThreadCount = std::clamp(advanced["Compiler Threads"].get(), 1, static_cast(std::thread::hardware_concurrency())); - if (advanced["Background Compiler Threads"].is_number_integer()) - shaderCache->backgroundCompilationThreadCount = std::clamp(advanced["Background Compiler Threads"].get(), 1, static_cast(std::thread::hardware_concurrency())); - if (advanced["Use FileWatcher"].is_boolean()) - shaderCache->SetFileWatcher(advanced["Use FileWatcher"]); - if (advanced["Frame Annotations"].is_boolean()) - frameAnnotations = advanced["Frame Annotations"]; - } - - if (settings["General"].is_object()) { - logger::info("Loading 'General' settings"); - json& general = settings["General"]; - - if (general["Enable Shaders"].is_boolean()) - shaderCache->SetEnabled(general["Enable Shaders"]); - - if (general["Enable Disk Cache"].is_boolean()) - shaderCache->SetDiskCache(general["Enable Disk Cache"]); - - if (general["Enable Async"].is_boolean()) - shaderCache->SetAsync(general["Enable Async"]); - } - - if (settings["Replace Original Shaders"].is_object()) { - logger::info("Loading 'Replace Original Shaders' settings"); - json& originalShaders = settings["Replace Original Shaders"]; - ForEachShaderTypeWithIndex([&](auto type, int classIndex) { - auto name = magic_enum::enum_name(type); - if (originalShaders[name].is_boolean()) { - enabledClasses[classIndex] = originalShaders[name]; - } else { - logger::warn("Invalid entry for shader class '{}', using default", name); - } - }); - } + // Load core settings (Menu, Advanced, General, Replace Original Shaders) + logger::info("Loading core settings"); + LoadFromJson(settings); // Ensure 'Disable at Boot' section exists in the JSON if (!settings.contains("Disable at Boot") || !settings["Disable at Boot"].is_object()) { // Initialize to an empty object if it doesn't exist @@ -416,26 +366,10 @@ void State::Load(ConfigMode a_configMode, bool a_allowReload) Load(a_configMode, false); } -void State::Save(ConfigMode a_configMode) +void State::SaveToJson(nlohmann::json& settings) { + std::lock_guard lock(m_mutex); const auto shaderCache = globals::shaderCache; - std::string configPath = GetConfigPath(a_configMode); - std::ofstream o{ configPath }; - - try { - std::filesystem::create_directories(Util::PathHelpers::GetCommunityShaderPath()); - } catch (const std::filesystem::filesystem_error& e) { - logger::warn("Error creating directory during Save ({}) : {}\n", Util::PathHelpers::GetCommunityShaderPath().string(), e.what()); - return; - } - - // Check if the file opened successfully - if (!o.is_open()) { - logger::warn("Failed to open config file for saving: {}", configPath); - return; // Exit early if file cannot be opened - } - - json settings; globals::menu->Save(settings["Menu"]); @@ -492,6 +426,86 @@ void State::Save(ConfigMode a_configMode) overrideManager->SaveUserOverride(featureName, currentSettings, overrideSettings); } } +} + +void State::LoadFromJson(nlohmann::json& settings) +{ + std::lock_guard lock(m_mutex); + const auto shaderCache = globals::shaderCache; + + // Load Menu settings + if (settings.contains("Menu") && settings["Menu"].is_object()) { + globals::menu->Load(settings["Menu"]); + } + + if (settings.contains("Advanced") && settings["Advanced"].is_object()) { + json& advanced = settings["Advanced"]; + if (advanced.contains("Dump Shaders") && advanced["Dump Shaders"].is_boolean()) + shaderCache->SetDump(advanced["Dump Shaders"]); + if (advanced.contains("Log Level") && advanced["Log Level"].is_number_integer()) + logLevel = magic_enum::enum_cast(advanced["Log Level"].get()).value_or(spdlog::level::info); + if (advanced.contains("Shader Defines") && advanced["Shader Defines"].is_string()) + SetDefines(advanced["Shader Defines"]); + if (advanced.contains("Compiler Threads") && advanced["Compiler Threads"].is_number_integer()) + shaderCache->compilationThreadCount = std::clamp(advanced["Compiler Threads"].get(), 1, static_cast(std::thread::hardware_concurrency())); + if (advanced.contains("Background Compiler Threads") && advanced["Background Compiler Threads"].is_number_integer()) + shaderCache->backgroundCompilationThreadCount = std::clamp(advanced["Background Compiler Threads"].get(), 1, static_cast(std::thread::hardware_concurrency())); + if (advanced.contains("Use FileWatcher") && advanced["Use FileWatcher"].is_boolean()) + shaderCache->SetFileWatcher(advanced["Use FileWatcher"]); + if (advanced.contains("Frame Annotations") && advanced["Frame Annotations"].is_boolean()) + frameAnnotations = advanced["Frame Annotations"]; + } + + if (settings.contains("General") && settings["General"].is_object()) { + json& general = settings["General"]; + if (general.contains("Enable Shaders") && general["Enable Shaders"].is_boolean()) + shaderCache->SetEnabled(general["Enable Shaders"]); + if (general.contains("Enable Disk Cache") && general["Enable Disk Cache"].is_boolean()) + shaderCache->SetDiskCache(general["Enable Disk Cache"]); + if (general.contains("Enable Async") && general["Enable Async"].is_boolean()) + shaderCache->SetAsync(general["Enable Async"]); + } + + if (settings.contains("Replace Original Shaders") && settings["Replace Original Shaders"].is_object()) { + json& originalShaders = settings["Replace Original Shaders"]; + ForEachShaderTypeWithIndex([&](auto type, int classIndex) { + auto name = magic_enum::enum_name(type); + if (originalShaders.contains(name) && originalShaders[name].is_boolean()) { + enabledClasses[classIndex] = originalShaders[name]; + } else { + logger::warn("Invalid entry for shader class '{}', using current value", name); + } + }); + } + + // Load feature settings (only for already-loaded features) + for (auto* feature : Feature::GetFeatureList()) { + if (feature->loaded) { + feature->Load(settings); + } + } +} + +void State::Save(ConfigMode a_configMode) +{ + std::string configPath = GetConfigPath(a_configMode); + std::ofstream o{ configPath }; + + try { + std::filesystem::create_directories(Util::PathHelpers::GetCommunityShaderPath()); + } catch (const std::filesystem::filesystem_error& e) { + logger::warn("Error creating directory during Save ({}) : {}\n", Util::PathHelpers::GetCommunityShaderPath().string(), e.what()); + return; + } + + // Check if the file opened successfully + if (!o.is_open()) { + logger::warn("Failed to open config file for saving: {}", configPath); + return; // Exit early if file cannot be opened + } + + json settings; + SaveToJson(settings); try { o << settings.dump(1); diff --git a/src/State.h b/src/State.h index d529e904ff..e8a9de3477 100644 --- a/src/State.h +++ b/src/State.h @@ -4,6 +4,7 @@ #include #include +#include #include using json = nlohmann::json; @@ -81,6 +82,10 @@ class State void Load(ConfigMode a_configMode = ConfigMode::USER, bool a_allowReload = true); void Save(ConfigMode a_configMode = ConfigMode::USER); + // In-memory serialization for A/B testing (avoids disk I/O during swaps) + void SaveToJson(nlohmann::json& o_json); + void LoadFromJson(nlohmann::json& i_json); + void LoadTheme(); void SaveTheme(); @@ -287,6 +292,7 @@ class State { "TruePBR", false } }; std::unordered_map disabledFeatures; + std::mutex m_mutex; inline ~State() { diff --git a/src/Utils/FileSystem.cpp b/src/Utils/FileSystem.cpp index f457e9fafb..ea2e9ea45e 100644 --- a/src/Utils/FileSystem.cpp +++ b/src/Utils/FileSystem.cpp @@ -1,5 +1,6 @@ #include "FileSystem.h" #include +#include #include #include #include @@ -206,48 +207,106 @@ namespace Util } } -std::vector Util::FileSystem::LoadJsonDiff(const std::filesystem::path& userPath, const std::filesystem::path& testPath) +std::vector Util::FileSystem::DiffJson(const nlohmann::json& userJson, const nlohmann::json& testJson, float epsilon) { std::vector diffEntries; try { - if (!std::filesystem::exists(userPath) || !std::filesystem::exists(testPath)) { + auto diff = nlohmann::json::diff(userJson, testJson); + + for (const auto& change : diff) { + try { + std::string op = change.value("op", ""); + std::string path = change.value("path", ""); + std::string aVal, bVal; + + if (op == "replace") { + auto aJson = userJson.at(nlohmann::json::json_pointer(path)); + auto bJson = testJson.at(nlohmann::json::json_pointer(path)); + + // If both values are numbers, check if difference is within epsilon (double precision) + if (aJson.is_number() && bJson.is_number()) { + double aDouble = aJson.get(); + double bDouble = bJson.get(); + if (std::abs(aDouble - bDouble) < static_cast(epsilon)) { + continue; // Skip insignificant numeric differences + } + } + + aVal = aJson.dump(); + bVal = bJson.dump(); + } else if (op == "add") { + aVal = "(none)"; + bVal = testJson.at(nlohmann::json::json_pointer(path)).dump(); + } else if (op == "remove") { + aVal = userJson.at(nlohmann::json::json_pointer(path)).dump(); + bVal = "(none)"; + } else { + logger::warn("Unknown JSON diff operation '{}' at path '{}'", op, path); + continue; + } + + diffEntries.push_back({ path, aVal, bVal }); + } catch (const std::exception& e) { + logger::warn("Failed to process JSON diff change: {}", e.what()); + // Continue processing other changes + } + } + } catch (const std::exception& e) { + logger::warn("Failed to compute JSON diff: {}", e.what()); + } + + return diffEntries; +} + +std::vector Util::FileSystem::LoadJsonDiff(const std::filesystem::path& userPath, const std::filesystem::path& testPath, float epsilon) +{ + std::vector diffEntries; + + try { + if (!std::filesystem::exists(userPath)) { + logger::warn("User config file does not exist: {}", userPath.string()); + return diffEntries; + } + + if (!std::filesystem::exists(testPath)) { + logger::warn("Test config file does not exist: {}", testPath.string()); return diffEntries; } std::ifstream userFile(userPath); std::ifstream testFile(testPath); - if (!userFile.is_open() || !testFile.is_open()) { + if (!userFile.is_open()) { + logger::warn("Failed to open user config file: {}", userPath.string()); return diffEntries; } - nlohmann::json userJson, testJson; - userFile >> userJson; - testFile >> testJson; + if (!testFile.is_open()) { + logger::warn("Failed to open test config file: {}", testPath.string()); + return diffEntries; + } - auto diff = nlohmann::json::diff(userJson, testJson); + nlohmann::json userJson, testJson; - for (const auto& change : diff) { - std::string op = change.value("op", ""); - std::string path = change.value("path", ""); - std::string aVal, bVal; - - if (op == "replace") { - aVal = userJson.at(nlohmann::json::json_pointer(path)).dump(); - bVal = testJson.at(nlohmann::json::json_pointer(path)).dump(); - } else if (op == "add") { - aVal = "(none)"; - bVal = testJson.at(nlohmann::json::json_pointer(path)).dump(); - } else if (op == "remove") { - aVal = userJson.at(nlohmann::json::json_pointer(path)).dump(); - bVal = "(none)"; - } + try { + userFile >> userJson; + } catch (const std::exception& e) { + logger::warn("Failed to parse user config JSON from '{}': {}", userPath.string(), e.what()); + return diffEntries; + } - diffEntries.push_back({ path, aVal, bVal }); + try { + testFile >> testJson; + } catch (const std::exception& e) { + logger::warn("Failed to parse test config JSON from '{}': {}", testPath.string(), e.what()); + return diffEntries; } + + // Use shared diffing logic + return DiffJson(userJson, testJson, epsilon); } catch (const std::exception& e) { - logger::warn("Failed to load JSON diff: {}", e.what()); + logger::warn("Failed to load JSON diff from '{}' and '{}': {}", userPath.string(), testPath.string(), e.what()); } return diffEntries; diff --git a/src/Utils/FileSystem.h b/src/Utils/FileSystem.h index f553995451..756d538bf4 100644 --- a/src/Utils/FileSystem.h +++ b/src/Utils/FileSystem.h @@ -228,6 +228,23 @@ namespace Util namespace FileSystem { - std::vector LoadJsonDiff(const std::filesystem::path& userPath, const std::filesystem::path& testPath); + /** + * Compares two JSON objects and returns a list of differences + * Core diffing logic shared between file-based and in-memory JSON comparisons + * @param userJson First JSON object (USER/baseline variant) + * @param testJson Second JSON object (TEST variant) + * @param epsilon Tolerance for floating-point comparisons (default: 0.0001f filters precision noise while preserving meaningful changes >0.01%) + * @return Vector of differences between the two JSON objects + */ + std::vector DiffJson(const nlohmann::json& userJson, const nlohmann::json& testJson, float epsilon = 0.0001f); + + /** + * Loads and compares two JSON files, returning a list of differences + * @param userPath Path to the first JSON file (USER variant) + * @param testPath Path to the second JSON file (TEST variant) + * @param epsilon Tolerance for floating-point comparisons (default: 0.0001f) + * @return Vector of differences between the two JSON files + */ + std::vector LoadJsonDiff(const std::filesystem::path& userPath, const std::filesystem::path& testPath, float epsilon = 0.0001f); } }