From a40bd3d21ccee3d048f6be22cf252476ff5722e2 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 00:55:55 -0500 Subject: [PATCH 01/11] Fix std::bad_function_call crash in HUDHandler::Process This commit fixes a crash that occurs when empty std::function objects are invoked in the task queue. The crash manifests as an unhandled std::bad_function_call exception. The issue occurs when tasks are added to the queue but the function object becomes invalid before execution (e.g., due to lambda capture lifetime issues or uninitialized functions). Changes: - Add null check before invoking std::function in HUDHandler::Process - Prevents crash when encountering empty/invalid function objects - Maintains existing functionality for valid functions Crash signature: - Exception: 0xE06D7363 (C++ exception) - Type: std::bad_function_call - Module: TrueHUD.dll This fix resolves crashes reported with Actor Info Bars and Boss Bars when used with certain follower frameworks. --- src/HUDHandler.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/HUDHandler.cpp b/src/HUDHandler.cpp index b6864c3..4abb4fa 100644 --- a/src/HUDHandler.cpp +++ b/src/HUDHandler.cpp @@ -766,11 +766,16 @@ void HUDHandler::Initialize() } -void HUDHandler::Process(TrueHUDMenu& a_menu, float a_deltaTime) + void HUDHandler::Process(TrueHUDMenu& a_menu, float a_deltaTime) { while (!_taskQueue.empty()) { auto& task = _taskQueue.front(); - task(a_menu); + // Fix for std::bad_function_call crash - check if function is valid + if (task) { + task(a_menu); + } else { + logger::warn("Skipping empty HUD task in queue"); + } _taskQueue.pop(); } From aaa0249e1919c067b59ef5629427de9d3fcfd2f2 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 13:51:40 -0500 Subject: [PATCH 02/11] Improve task queue handling Ensures thread-safe task processing in HUDHandler and TrueHUDAPI by draining the task queues under lock and processing them outside the lock. This resolves potential data races when accessing shared resources from different threads. Allows callers to override the `SKSE_SUPPORT_XBYAK` option through CMake. This gives downstream packagers greater control over which third-party libraries are used. Guards `REL::Module::reset()` with a preprocessor definition for compatibility with standard CommonLibSSE-NG builds. --- src/CMakeLists.txt | 5 ++++- src/HUDHandler.cpp | 20 ++++++++++++++------ src/TrueHUDAPI.h | 15 +++++++++++---- src/main.cpp | 4 ++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b21b549..b38150c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -106,7 +106,10 @@ target_include_directories( "${SOURCE_DIR}" ) -set(SKSE_SUPPORT_XBYAK ON) +# Allow callers to override Xbyak support; default to ON if not explicitly set +if(NOT DEFINED SKSE_SUPPORT_XBYAK) + set(SKSE_SUPPORT_XBYAK ON) +endif() add_subdirectory("$ENV{CommonLibSSEPath_NG}" CommonLibSSE EXCLUDE_FROM_ALL) find_package(xbyak REQUIRED CONFIG) diff --git a/src/HUDHandler.cpp b/src/HUDHandler.cpp index 4abb4fa..09ce8c5 100644 --- a/src/HUDHandler.cpp +++ b/src/HUDHandler.cpp @@ -237,7 +237,8 @@ HUDHandler::EventResult HUDHandler::ProcessEvent(const RE::MenuOpenCloseEvent* a // Hide the widgets when a menu is open if (const auto controlMap = RE::ControlMap::GetSingleton()) { - const auto& priorityStack = controlMap->GetRuntimeData().contextPriorityStack; + // CommonLibSSE-NG exposes contextPriorityStack directly on ControlMap + const auto& priorityStack = controlMap->contextPriorityStack; if (priorityStack.empty()) { HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kHidden); } else if (priorityStack.back() == ContextID::kGameplay || @@ -768,15 +769,22 @@ void HUDHandler::Initialize() void HUDHandler::Process(TrueHUDMenu& a_menu, float a_deltaTime) { - while (!_taskQueue.empty()) { - auto& task = _taskQueue.front(); - // Fix for std::bad_function_call crash - check if function is valid + // Drain tasks under lock to avoid data races + std::queue localTasks; + { + Locker locker(_lock); + std::swap(localTasks, _taskQueue); + } + + // Process drained tasks outside the lock + while (!localTasks.empty()) { + auto task = std::move(localTasks.front()); + localTasks.pop(); if (task) { task(a_menu); } else { - logger::warn("Skipping empty HUD task in queue"); + logger::warn("Skipping empty HUD task (drained)"); } - _taskQueue.pop(); } for (auto it = _stackingDamage.begin(), next_it = it; it != _stackingDamage.end(); it = next_it) { diff --git a/src/TrueHUDAPI.h b/src/TrueHUDAPI.h index 2ad5590..da771a0 100644 --- a/src/TrueHUDAPI.h +++ b/src/TrueHUDAPI.h @@ -134,10 +134,17 @@ namespace TRUEHUD_API void ProcessDelegates() { - while (!_taskQueue.empty()) { - auto& task = _taskQueue.front(); - task(); - _taskQueue.pop(); + std::queue local; + { + Locker locker(_lock); + std::swap(local, _taskQueue); + } + while (!local.empty()) { + auto t = std::move(local.front()); + local.pop(); + if (t) { + t(); + } } } diff --git a/src/main.cpp b/src/main.cpp index d858eac..5201d14 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -102,7 +102,11 @@ extern "C" DLLEXPORT bool SKSEAPI SKSEPlugin_Load(const SKSE::LoadInterface* a_s #ifndef NDEBUG while (!IsDebuggerPresent()) { Sleep(100); } #endif + // REL::Module::reset() is only available in CommonLibSSE-NG when ENABLE_COMMONLIBSSE_TESTING is defined. + // It is not present in standard builds, so guard it for compatibility. + #ifdef ENABLE_COMMONLIBSSE_TESTING REL::Module::reset(); // Clib-NG bug workaround + #endif InitializeLog(); logger::info("{} v{}"sv, Plugin::NAME, Plugin::VERSION.string()); From b69ce3ea01a4f62a6808008b40e427b0cc3418fc Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 16:39:19 -0500 Subject: [PATCH 03/11] Add CI workflow, improve task handling, and implement unit tests --- .github/workflows/ci.yml | 56 +++++++++++ .gitignore | 6 +- CMakeLists.txt | 11 ++- src/CMakeLists.txt | 1 + src/HUDHandler.cpp | 14 +-- src/HUDHandler.h | 49 +++++++++- tests/CMakeLists.txt | 41 ++++++++ tests/hudhandler_process_tests.cpp | 151 +++++++++++++++++++++++++++++ tests/widgetbase_tests.cpp | 99 +++++++++++++++++++ vcpkg.json | 5 +- 10 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/CMakeLists.txt create mode 100644 tests/hudhandler_process_tests.cpp create mode 100644 tests/widgetbase_tests.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..747394c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + push: + branches: [ master, main, develop, fix-* ] + +jobs: + build-and-test-windows: + runs-on: windows-latest + env: + BUILD_DIR: ${{ github.workspace }}\build + ARTIFACTS_DIR: ${{ github.workspace }}\artifacts + COMMONLIBSSE_NG_DIR: ${{ github.workspace }}\deps\CommonLibSSE-NG + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup vcpkg + uses: microsoft/vcpkg-action@v1 + with: + # Use classic mode so the toolchain file path is predictable + vcpkgGitCommitId: '' + + - name: Clone CommonLibSSE-NG + run: | + git clone --depth=1 https://github.com/CharmedBaryon/CommonLibSSE-NG "${{ env.COMMONLIBSSE_NG_DIR }}" + + - name: Configure (CMake, VS 2022, x64) + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "${{ env.BUILD_DIR }}" | Out-Null + $env:CommonLibSSEPath_NG = "${{ env.COMMONLIBSSE_NG_DIR }}" + $env:CompiledPluginsPath = "${{ env.ARTIFACTS_DIR }}" + cmake -S . -B "${{ env.BUILD_DIR }}" ` + -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="${{ env.VCPKG_ROOT }}\scripts\buildsystems\vcpkg.cmake" ` + -DBUILD_TESTS=ON + + - name: Build (Debug) + shell: pwsh + run: | + cmake --build "${{ env.BUILD_DIR }}" --config Debug --parallel + + - name: Run tests + shell: pwsh + run: | + ctest --test-dir "${{ env.BUILD_DIR }}" -C Debug --output-on-failure + + - name: Upload artifacts (DLL/PDB) + if: always() + uses: actions/upload-artifact@v4 + with: + name: truehud-artifacts + path: | + ${{ env.ARTIFACTS_DIR }}/**/* diff --git a/.gitignore b/.gitignore index e27ba92..9628aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -421,5 +421,9 @@ FodyWeavers.xsd # End of https://www.toptal.com/developers/gitignore/api/cmake,visualstudio -/build +# Project-specific ignores +/build*/ +/vcpkg_installed/ +/CMakeUserPresets.json +/CMakeUserPresets.json.user *.swf diff --git a/CMakeLists.txt b/CMakeLists.txt index c123874..7345b4f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,17 +18,24 @@ macro(set_from_environment VARIABLE) endmacro() set_from_environment(CompiledPluginsPath) +# Provide a default deploy path if not specified via environment if(NOT DEFINED CompiledPluginsPath) - message(FATAL_ERROR "CompiledPluginsPath is not set") + set(CompiledPluginsPath "${CMAKE_BINARY_DIR}/_deploy") endif() option(COPY_OUTPUT "Copy the output of build operations to the game directory" ON) option(ENABLE_SKYRIM_SE "Enable support for Skyrim SE in the dynamic runtime feature." ON) option(ENABLE_SKYRIM_AE "Enable support for Skyrim AE in the dynamic runtime feature." ON) option(ENABLE_SKYRIM_VR "Enable support for Skyrim VR in the dynamic runtime feature." OFF) -set(BUILD_TESTS OFF) +option(BUILD_TESTS "Build unit tests" ON) list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake") add_subdirectory(src) + +if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + include(cmake/packaging.cmake) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b38150c..09899e7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -145,6 +145,7 @@ if("${COPY_OUTPUT}") add_custom_command( TARGET "${PROJECT_NAME}" POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CompiledPluginsPath}/SKSE/Plugins" COMMAND "${CMAKE_COMMAND}" -E copy_if_different "$" "${CompiledPluginsPath}/SKSE/Plugins/" COMMAND "${CMAKE_COMMAND}" -E copy_if_different "$" "${CompiledPluginsPath}/SKSE/Plugins/" VERBATIM diff --git a/src/HUDHandler.cpp b/src/HUDHandler.cpp index 09ce8c5..aa295a0 100644 --- a/src/HUDHandler.cpp +++ b/src/HUDHandler.cpp @@ -769,23 +769,13 @@ void HUDHandler::Initialize() void HUDHandler::Process(TrueHUDMenu& a_menu, float a_deltaTime) { - // Drain tasks under lock to avoid data races - std::queue localTasks; - { - Locker locker(_lock); - std::swap(localTasks, _taskQueue); - } - - // Process drained tasks outside the lock - while (!localTasks.empty()) { - auto task = std::move(localTasks.front()); - localTasks.pop(); + DrainAndProcessTasks([&](HUDTask& task) { if (task) { task(a_menu); } else { logger::warn("Skipping empty HUD task (drained)"); } - } + }); for (auto it = _stackingDamage.begin(), next_it = it; it != _stackingDamage.end(); it = next_it) { ++next_it; diff --git a/src/HUDHandler.h b/src/HUDHandler.h index 1ac9828..c20bed8 100644 --- a/src/HUDHandler.h +++ b/src/HUDHandler.h @@ -3,9 +3,18 @@ #include #include "TrueHUDAPI.h" +#if defined(TRUEHUD_TESTING) +namespace Scaleform { + class TrueHUDMenu { + public: + enum class MenuVisibilityMode : uint8_t { kHidden, kPartial, kVisible }; + }; +} +#else #include "Widgets/ActorInfoBar.h" #include "Widgets/BossInfoBar.h" #include "Scaleform/TrueHUDMenu.h" +#endif class HUDHandler : public RE::BSTEventSink, @@ -25,19 +34,25 @@ class HUDHandler : using MenuVisibilityMode = TrueHUDMenu::MenuVisibilityMode; public: +#if defined(TRUEHUD_TESTING) + static HUDHandler* GetSingleton() { return nullptr; } +#else static HUDHandler* GetSingleton() { static HUDHandler singleton; return std::addressof(singleton); } +#endif static void Register(); +#ifndef TRUEHUD_TESTING virtual EventResult ProcessEvent(const RE::TESCombatEvent* a_event, RE::BSTEventSource* a_eventSource) override; virtual EventResult ProcessEvent(const RE::TESDeathEvent* a_event, RE::BSTEventSource* a_eventSource) override; virtual EventResult ProcessEvent(const RE::TESEnterBleedoutEvent* a_event, RE::BSTEventSource* a_eventSource) override; virtual EventResult ProcessEvent(const RE::TESHitEvent* a_event, RE::BSTEventSource* a_eventSource) override; virtual EventResult ProcessEvent(const RE::MenuOpenCloseEvent* a_event, RE::BSTEventSource* a_eventSource) override; +#endif void OpenTrueHUDMenu(); void CloseTrueHUDMenu(); @@ -128,9 +143,34 @@ class HUDHandler : using Lock = std::recursive_mutex; using Locker = std::lock_guard; +#ifdef TRUEHUD_TESTING +public: +#endif + template + void DrainAndProcessTasks(Invoker&& invoker) + { + std::queue localTasks; + { + Locker locker(_lock); + std::swap(localTasks, _taskQueue); + } + while (!localTasks.empty()) { + auto task = std::move(localTasks.front()); + localTasks.pop(); + invoker(task); + } + } + +#ifndef TRUEHUD_TESTING HUDHandler(); - HUDHandler(const HUDHandler&) = delete; - HUDHandler(HUDHandler&&) = delete; +#else +public: + // Test-only defaulted constructor so unit tests don't require the out-of-line definition from HUDHandler.cpp + HUDHandler() = default; +private: +#endif +HUDHandler(const HUDHandler&) = delete; +HUDHandler(HUDHandler&&) = delete; ~HUDHandler() = default; @@ -154,6 +194,11 @@ class HUDHandler : float damage = 0.f; float timeElapsed = 0.f; }; +#if defined(TRUEHUD_TESTING) + using TestObjectHandle = std::uintptr_t; + std::unordered_map _stackingDamage; +#else std::unordered_map _stackingDamage; +#endif constexpr static float _stackingPeriodDuration = 0.5f; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..7e8faf7 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.22) + +# Tests for TrueHUD + +find_package(Catch2 3 CONFIG REQUIRED) + +add_executable(truehud_tests + ${CMAKE_CURRENT_SOURCE_DIR}/widgetbase_tests.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/hudhandler_process_tests.cpp +) + +target_compile_features(truehud_tests PRIVATE cxx_std_20) + +# Avoid Windows macro pollution (e.g., min/max) that conflicts with standard library and Catch2 + target_compile_definitions(truehud_tests PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN TRUEHUD_TESTING=1) + + +# Include the project source headers and the generated Plugin.h from the src subdir +# We need ${CMAKE_BINARY_DIR}/src because src/CMakeLists.txt generates Plugin.h there. +# We include ${CMAKE_SOURCE_DIR}/src so we can include PCH.h and TrueHUDAPI.h. + +target_include_directories(truehud_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_BINARY_DIR}/src + ${CMAKE_BINARY_DIR}/src/src +) + +# Link Catch2 main; also link CommonLibSSE to satisfy RE/SKSE symbols used by PCH.h/headers (compile-only usage) +# CommonLibSSE is added by src/CMakeLists.txt, which our root CMake adds before this tests subdir. + +target_link_libraries(truehud_tests PRIVATE + Catch2::Catch2WithMain + CommonLibSSE::CommonLibSSE +) + +# Ensure the generated headers from the src subproject exist before building tests +add_dependencies(truehud_tests TrueHUD) + +include(CTest) +include(Catch) +catch_discover_tests(truehud_tests) diff --git a/tests/hudhandler_process_tests.cpp b/tests/hudhandler_process_tests.cpp new file mode 100644 index 0000000..70a76fa --- /dev/null +++ b/tests/hudhandler_process_tests.cpp @@ -0,0 +1,151 @@ +#include "PCH.h" + +// Test HUDHandler drain/guard behavior without invoking game UI. + +#define private public +#define protected public +#include "HUDHandler.h" +#undef private +#undef protected + +#include +#include +#include +#include +#include + +using ScaleformMenu = Scaleform::TrueHUDMenu; // alias for signature clarity + +namespace { + // Utility to clear handler state between tests + void ClearHandlerQueues(HUDHandler& h) { + HUDHandler::Locker lock(h._lock); + while (!h._taskQueue.empty()) h._taskQueue.pop(); + // Do not touch _stackingDamage to avoid including hashing machinery in tests + } + + struct TestHUDHandler : HUDHandler { + using EventResult = RE::BSEventNotifyControl; + EventResult ProcessEvent(const RE::TESCombatEvent*, RE::BSTEventSource*) override { return EventResult::kContinue; } + EventResult ProcessEvent(const RE::TESDeathEvent*, RE::BSTEventSource*) override { return EventResult::kContinue; } + EventResult ProcessEvent(const RE::TESEnterBleedoutEvent*, RE::BSTEventSource*) override { return EventResult::kContinue; } + EventResult ProcessEvent(const RE::TESHitEvent*, RE::BSTEventSource*) override { return EventResult::kContinue; } + EventResult ProcessEvent(const RE::MenuOpenCloseEvent*, RE::BSTEventSource*) override { return EventResult::kContinue; } + }; +} + +TEST_CASE("HUDHandler DrainAndProcessTasks skips empty tasks and does not throw", "[hudhandler][empty]") { + TestHUDHandler h; + ClearHandlerQueues(h); + + std::atomic executed{0}; + + { + HUDHandler::Locker lock(h._lock); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { ++executed; }}); + h._taskQueue.push(HUDHandler::HUDTask{}); // empty + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { ++executed; }}); + } + + REQUIRE_NOTHROW(h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) { t(*reinterpret_cast(nullptr)); /* never deref in task */ ++executed; --executed; } + // The line above increments and decrements to avoid unused param warnings while not changing executed count. + })); + + // Execute tasks but without calling into the parameter; validate we only saw the two valid tasks + // We track success by having the tasks themselves increment 'executed'. + REQUIRE(executed.load() == 2); +} + +TEST_CASE("HUDHandler DrainAndProcessTasks executes tasks exactly once and in FIFO order (per drained batch)", "[hudhandler][fifo]") { + TestHUDHandler h; + ClearHandlerQueues(h); + + std::vector order; + order.reserve(3); + + { + HUDHandler::Locker lock(h._lock); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { order.push_back(1); }}); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { order.push_back(2); }}); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { order.push_back(3); }}); + } + + h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) t(*reinterpret_cast(nullptr)); + }); + + REQUIRE(order.size() == 3); + REQUIRE(order[0] == 1); + REQUIRE(order[1] == 2); + REQUIRE(order[2] == 3); +} + +TEST_CASE("HUDHandler reentrancy: tasks enqueued during processing run on the next pass", "[hudhandler][reentrancy]") { + TestHUDHandler h; + ClearHandlerQueues(h); + + std::atomic executed{0}; + + { + HUDHandler::Locker lock(h._lock); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { + ++executed; // 1st + // Enqueue another task during processing; should run in next pass only + HUDHandler::Locker lock2(h._lock); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { ++executed; }}); // 2nd + }}); + } + + h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) t(*reinterpret_cast(nullptr)); + }); + + REQUIRE(executed.load() == 1); + + h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) t(*reinterpret_cast(nullptr)); + }); + + REQUIRE(executed.load() == 2); +} + +TEST_CASE("HUDHandler concurrent producers: all tasks execute without data races", "[hudhandler][concurrency]") { + TestHUDHandler h; + ClearHandlerQueues(h); + + constexpr int Producers = 4; + constexpr int TasksPerProducer = 250; + constexpr int Total = Producers * TasksPerProducer; + + std::atomic executed{0}; + + std::vector threads; + threads.reserve(Producers); + + for (int p = 0; p < Producers; ++p) { + threads.emplace_back([&] { + for (int i = 0; i < TasksPerProducer; ++i) { + HUDHandler::Locker lock(h._lock); + h._taskQueue.push(HUDHandler::HUDTask{[&](ScaleformMenu&) { executed.fetch_add(1, std::memory_order_relaxed); }}); + } + }); + } + + using namespace std::chrono_literals; + + for (int i = 0; i < 10; ++i) { + h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) t(*reinterpret_cast(nullptr)); + }); + std::this_thread::sleep_for(5ms); + } + + for (auto& t : threads) t.join(); + + h.DrainAndProcessTasks([&](HUDHandler::HUDTask& t) { + if (t) t(*reinterpret_cast(nullptr)); + }); + + REQUIRE(executed.load() == Total); +} diff --git a/tests/widgetbase_tests.cpp b/tests/widgetbase_tests.cpp new file mode 100644 index 0000000..8a08691 --- /dev/null +++ b/tests/widgetbase_tests.cpp @@ -0,0 +1,99 @@ +#include "PCH.h" + +#include +#include +#include +#include +#include + +#include "TrueHUDAPI.h" + +namespace { + struct TestWidget final : TRUEHUD_API::WidgetBase { + void Update(float) override {} + void Initialize() override {} + void Dispose() override {} + + void Enqueue(TRUEHUD_API::WidgetBase::WidgetTask t) { AddWidgetTask(std::move(t)); } + }; +} + +TEST_CASE("ProcessDelegates skips empty tasks and does not throw", "[widgetbase][empty]") { + TestWidget w; + std::atomic executed{0}; + + w.Enqueue([&] { ++executed; }); + w.Enqueue(TRUEHUD_API::WidgetBase::WidgetTask{}); // empty + w.Enqueue([&] { ++executed; }); + + REQUIRE_NOTHROW(w.ProcessDelegates()); + REQUIRE(executed.load() == 2); +} + +TEST_CASE("ProcessDelegates executes tasks exactly once and in FIFO order (per drained batch)", "[widgetbase][fifo]") { + TestWidget w; + std::vector order; + order.reserve(3); + + w.Enqueue([&] { order.push_back(1); }); + w.Enqueue([&] { order.push_back(2); }); + w.Enqueue([&] { order.push_back(3); }); + + w.ProcessDelegates(); + + REQUIRE(order.size() == 3); + REQUIRE(order[0] == 1); + REQUIRE(order[1] == 2); + REQUIRE(order[2] == 3); +} + +TEST_CASE("Reentrancy: tasks enqueued by a running task are executed in the next pass", "[widgetbase][reentrancy]") { + TestWidget w; + std::atomic executed{0}; + + w.Enqueue([&] { + ++executed; // 1st + // Enqueue another task during processing; should run on next pass + w.Enqueue([&] { ++executed; }); // 2nd + }); + + // First drain: runs first task; second task is enqueued after drain started + w.ProcessDelegates(); + REQUIRE(executed.load() == 1); + + // Second drain: picks up the newly enqueued task + w.ProcessDelegates(); + REQUIRE(executed.load() == 2); +} + +TEST_CASE("Concurrent producers: all tasks are executed without data races", "[widgetbase][concurrency]") { + TestWidget w; + constexpr int Producers = 4; + constexpr int TasksPerProducer = 250; + constexpr int Total = Producers * TasksPerProducer; + + std::atomic executed{0}; + + // Producers push tasks concurrently + std::vector threads; + threads.reserve(Producers); + for (int p = 0; p < Producers; ++p) { + threads.emplace_back([&] { + for (int i = 0; i < TasksPerProducer; ++i) { + w.Enqueue([&] { executed.fetch_add(1, std::memory_order_relaxed); }); + } + }); + } + + using namespace std::chrono_literals; + // Consumer: drain periodically while producers run + for (int i = 0; i < 10; ++i) { + w.ProcessDelegates(); + std::this_thread::sleep_for(5ms); + } + + for (auto& t : threads) t.join(); + w.ProcessDelegates(); + + REQUIRE(executed.load() == Total); +} diff --git a/vcpkg.json b/vcpkg.json index 9db3ab0..45e055b 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -8,6 +8,7 @@ "rsm-binary-io", "simpleini", "spdlog", - "xbyak" + "xbyak", + "catch2" ] -} \ No newline at end of file +} From ce3d8481170e4da53aaf753fecea407cdc0d2d73 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 16:45:42 -0500 Subject: [PATCH 04/11] Update CI workflow to install vcpkg using a direct clone instead of the vcpkg-action --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747394c..8dda922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +12,19 @@ jobs: BUILD_DIR: ${{ github.workspace }}\build ARTIFACTS_DIR: ${{ github.workspace }}\artifacts COMMONLIBSSE_NG_DIR: ${{ github.workspace }}\deps\CommonLibSSE-NG + VCPKG_ROOT: ${{ runner.temp }}\vcpkg steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup vcpkg - uses: microsoft/vcpkg-action@v1 - with: - # Use classic mode so the toolchain file path is predictable - vcpkgGitCommitId: '' + - name: Install vcpkg (classic) + shell: pwsh + run: | + git clone --depth=1 https://github.com/microsoft/vcpkg "${{ env.VCPKG_ROOT }}" + & "${{ env.VCPKG_ROOT }}\bootstrap-vcpkg.bat" - name: Clone CommonLibSSE-NG + shell: pwsh run: | git clone --depth=1 https://github.com/CharmedBaryon/CommonLibSSE-NG "${{ env.COMMONLIBSSE_NG_DIR }}" From 8f83666d6d105bcf90afed032d27b6f266a23824 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 17:02:57 -0500 Subject: [PATCH 05/11] Update CI workflow to set VCPKG_ROOT to a consistent path and enhance caching for vcpkg installed packages --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dda922..14c3bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,27 @@ jobs: BUILD_DIR: ${{ github.workspace }}\build ARTIFACTS_DIR: ${{ github.workspace }}\artifacts COMMONLIBSSE_NG_DIR: ${{ github.workspace }}\deps\CommonLibSSE-NG - VCPKG_ROOT: ${{ runner.temp }}\vcpkg + VCPKG_ROOT: ${{ github.workspace }}\deps\vcpkg + VCPKG_DEFAULT_TRIPLET: x64-windows steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup CMake + uses: lukka/get-cmake@v3 + + - name: Cache vcpkg installed packages + uses: actions/cache@v4 + with: + path: | + ${{ env.VCPKG_ROOT }}\installed + ${{ env.VCPKG_ROOT }}\buildtrees + ${{ env.VCPKG_ROOT }}\packages + ${{ env.USERPROFILE }}\AppData\Local\vcpkg\archives + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }} + restore-keys: | + vcpkg-${{ runner.os }} + - name: Install vcpkg (classic) shell: pwsh run: | From d4d7c9cb7465893031b35863c514707d07840ebb Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 17:30:16 -0500 Subject: [PATCH 06/11] Replace CMake setup action with version check in CI workflow --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c3bb2..c5516d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup CMake - uses: lukka/get-cmake@v3 + - name: Check CMake version + shell: pwsh + run: cmake --version - name: Cache vcpkg installed packages uses: actions/cache@v4 From a559a652f045cd241afda8e5ab7a9e6af53008e4 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Fri, 26 Sep 2025 23:13:26 -0500 Subject: [PATCH 07/11] Refactor HUD visibility handling to avoid crashes and improve menu state management; add optional debugger wait and hooks installation logging --- src/HUDHandler.cpp | 33 ++++++++++++++------------------- src/main.cpp | 14 +++++++++++--- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/HUDHandler.cpp b/src/HUDHandler.cpp index aa295a0..3f0848a 100644 --- a/src/HUDHandler.cpp +++ b/src/HUDHandler.cpp @@ -236,26 +236,21 @@ HUDHandler::EventResult HUDHandler::ProcessEvent(const RE::MenuOpenCloseEvent* a } // Hide the widgets when a menu is open - if (const auto controlMap = RE::ControlMap::GetSingleton()) { - // CommonLibSSE-NG exposes contextPriorityStack directly on ControlMap - const auto& priorityStack = controlMap->contextPriorityStack; - if (priorityStack.empty()) { - HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kHidden); - } else if (priorityStack.back() == ContextID::kGameplay || - priorityStack.back() == ContextID::kFavorites || - priorityStack.back() == ContextID::kConsole) { - HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kVisible); - } else if ((priorityStack.back() == ContextID::kCursor || - priorityStack.back() == ContextID::kItemMenu || - priorityStack.back() == ContextID::kMenuMode || - priorityStack.back() == ContextID::kInventory) && - (RE::UI::GetSingleton()->IsMenuOpen(RE::DialogueMenu::MENU_NAME) || - !Settings::bRecentLootHideInCraftingMenus && RE::UI::GetSingleton()->IsMenuOpen(RE::CraftingMenu::MENU_NAME) || - !Settings::bRecentLootHideInInventoryMenus && (RE::UI::GetSingleton()->IsMenuOpen(RE::BarterMenu::MENU_NAME) || - RE::UI::GetSingleton()->IsMenuOpen(RE::ContainerMenu::MENU_NAME) || - RE::UI::GetSingleton()->IsMenuOpen(RE::GiftMenu::MENU_NAME) || - RE::UI::GetSingleton()->IsMenuOpen(RE::InventoryMenu::MENU_NAME)))) { + // NOTE: Avoid using ControlMap::contextPriorityStack due to reported crashes on some setups + if (const auto ui = RE::UI::GetSingleton()) { + const bool dialogueOpen = ui->IsMenuOpen(RE::DialogueMenu::MENU_NAME); + const bool craftingOpen = !Settings::bRecentLootHideInCraftingMenus && ui->IsMenuOpen(RE::CraftingMenu::MENU_NAME); + const bool inventoryOpen = !Settings::bRecentLootHideInInventoryMenus && ( + ui->IsMenuOpen(RE::BarterMenu::MENU_NAME) || + ui->IsMenuOpen(RE::ContainerMenu::MENU_NAME) || + ui->IsMenuOpen(RE::GiftMenu::MENU_NAME) || + ui->IsMenuOpen(RE::InventoryMenu::MENU_NAME)); + + if (dialogueOpen || craftingOpen || inventoryOpen) { HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kPartial); + } else if (ui->IsMenuOpen(RE::HUDMenu::MENU_NAME)) { + // Treat gameplay/favorites/console cases as visible; HUD is still present in these + HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kVisible); } else { HUDHandler::GetSingleton()->SetMenuVisibilityMode(MenuVisibilityMode::kHidden); } diff --git a/src/main.cpp b/src/main.cpp index 5201d14..9c193b0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include "HUDHandler.h" #include "Scaleform/Scaleform.h" #include "NPCNameProvider.h" +#include void MessageHandler(SKSE::MessagingInterface::Message* a_msg) { @@ -99,9 +100,10 @@ extern "C" DLLEXPORT constinit auto SKSEPlugin_Version = []() { extern "C" DLLEXPORT bool SKSEAPI SKSEPlugin_Load(const SKSE::LoadInterface* a_skse) { -#ifndef NDEBUG +// Optional debugger wait: enable only if TRUEHUD_WAIT_FOR_DEBUGGER is set in the environment. +if (std::getenv("TRUEHUD_WAIT_FOR_DEBUGGER")) { while (!IsDebuggerPresent()) { Sleep(100); } -#endif +} // REL::Module::reset() is only available in CommonLibSSE-NG when ENABLE_COMMONLIBSSE_TESTING is defined. // It is not present in standard builds, so guard it for compatibility. #ifdef ENABLE_COMMONLIBSSE_TESTING @@ -119,7 +121,13 @@ extern "C" DLLEXPORT bool SKSEAPI SKSEPlugin_Load(const SKSE::LoadInterface* a_s return false; } - Hooks::Install(); + if (std::getenv("TRUEHUD_DISABLE_HOOKS")) { + logger::warn("TRUEHUD_DISABLE_HOOKS set; skipping Hooks::Install()"); + } else { + logger::info("Installing hooks..."); + Hooks::Install(); + logger::info("Hooks installed."); + } Papyrus::Register(); return true; From b99467ca1d67b2cc235c4349def8fb6369e38c6a Mon Sep 17 00:00:00 2001 From: Agent Mode Date: Sat, 27 Sep 2025 00:53:06 -0500 Subject: [PATCH 08/11] CI: clean deps/vcpkg before vcpkg setup to fix bootstrap and existing dir issues --- .github/workflows/ci.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5516d0..81be1c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,23 +22,29 @@ jobs: shell: pwsh run: cmake --version + - name: Remove existing vcpkg directory + shell: pwsh + run: | + if (Test-Path "deps/vcpkg") { Remove-Item -Recurse -Force "deps/vcpkg" } + - name: Cache vcpkg installed packages uses: actions/cache@v4 with: path: | - ${{ env.VCPKG_ROOT }}\installed - ${{ env.VCPKG_ROOT }}\buildtrees - ${{ env.VCPKG_ROOT }}\packages - ${{ env.USERPROFILE }}\AppData\Local\vcpkg\archives + ${{ env.VCPKG_ROOT }}\\installed + ${{ env.VCPKG_ROOT }}\\buildtrees + ${{ env.VCPKG_ROOT }}\\packages + ${{ env.USERPROFILE }}\\AppData\\Local\\vcpkg\\archives key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }} restore-keys: | vcpkg-${{ runner.os }} + - name: Install vcpkg (classic) shell: pwsh run: | git clone --depth=1 https://github.com/microsoft/vcpkg "${{ env.VCPKG_ROOT }}" - & "${{ env.VCPKG_ROOT }}\bootstrap-vcpkg.bat" + & "${{ env.VCPKG_ROOT }}\\bootstrap-vcpkg.bat" - name: Clone CommonLibSSE-NG shell: pwsh From 24882dbe2782b86c2a27815e6d2ef666587cbd09 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Sat, 27 Sep 2025 03:01:26 -0500 Subject: [PATCH 09/11] CI: use vcpkg workspace binary cache and cache only that directory; replace USERPROFILE path with HOME and pre-create cache dir --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81be1c9..87149b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: COMMONLIBSSE_NG_DIR: ${{ github.workspace }}\deps\CommonLibSSE-NG VCPKG_ROOT: ${{ github.workspace }}\deps\vcpkg VCPKG_DEFAULT_TRIPLET: x64-windows + VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}\\vcpkg-bincache steps: - name: Checkout uses: actions/checkout@v4 @@ -27,14 +28,16 @@ jobs: run: | if (Test-Path "deps/vcpkg") { Remove-Item -Recurse -Force "deps/vcpkg" } + - name: Ensure vcpkg binary cache directory exists + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "$env:VCPKG_DEFAULT_BINARY_CACHE" | Out-Null + - name: Cache vcpkg installed packages uses: actions/cache@v4 with: path: | - ${{ env.VCPKG_ROOT }}\\installed - ${{ env.VCPKG_ROOT }}\\buildtrees - ${{ env.VCPKG_ROOT }}\\packages - ${{ env.USERPROFILE }}\\AppData\\Local\\vcpkg\\archives + ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }} restore-keys: | vcpkg-${{ runner.os }} From f32d926ed8f994ef73737cb4b0b2d92eba8488e4 Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Mon, 29 Sep 2025 20:29:38 -0500 Subject: [PATCH 10/11] Streamline build configuration and fix compilation issues - Fix HUDHandler: Move EventResult type alias to public scope to resolve access issues - Fix main.cpp: Use RUNTIME_SSE_LATEST constant and improve SKSEPlugin_Version initialisation - Fix variable shadowing in HUDHandler.cpp and InfoBarBase.cpp - Streamline CMakePresets.json: Remove Debug presets, keep only Release configuration - Update CI workflow: Build Release artifacts instead of Debug, add verification step - Improve runtime compatibility: Support both SSE 1.5.97 and latest AE versions --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++---- CMakePresets.json | 12 ++++++------ src/HUDHandler.cpp | 20 +++++++++++++++----- src/HUDHandler.h | 4 +++- src/Widgets/InfoBarBase.cpp | 1 + src/main.cpp | 10 +++++++--- 6 files changed, 54 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87149b4..1295a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,17 +63,35 @@ jobs: cmake -S . -B "${{ env.BUILD_DIR }}" ` -G "Visual Studio 17 2022" -A x64 ` -DCMAKE_TOOLCHAIN_FILE="${{ env.VCPKG_ROOT }}\scripts\buildsystems\vcpkg.cmake" ` + -DCMAKE_BUILD_TYPE=Release ` -DBUILD_TESTS=ON - - name: Build (Debug) + - name: Build (Release) shell: pwsh run: | - cmake --build "${{ env.BUILD_DIR }}" --config Debug --parallel + $env:CommonLibSSEPath_NG = "${{ env.COMMONLIBSSE_NG_DIR }}" + $env:CompiledPluginsPath = "${{ env.ARTIFACTS_DIR }}" + cmake --build "${{ env.BUILD_DIR }}" --config Release --parallel + + - name: Run tests (Release) + shell: pwsh + run: | + ctest --test-dir "${{ env.BUILD_DIR }}" -C Release --output-on-failure - - name: Run tests + - name: Verify artifacts shell: pwsh run: | - ctest --test-dir "${{ env.BUILD_DIR }}" -C Debug --output-on-failure + Write-Host "Verifying Release build artifacts..." + Get-ChildItem -Path "${{ env.ARTIFACTS_DIR }}" -Recurse -File | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize + if (-not (Test-Path "${{ env.ARTIFACTS_DIR }}\SKSE\Plugins\TrueHUD.dll")) { + Write-Error "TrueHUD.dll not found in artifacts directory!" + exit 1 + } + $dllSize = (Get-Item "${{ env.ARTIFACTS_DIR }}\SKSE\Plugins\TrueHUD.dll").Length + Write-Host "TrueHUD.dll size: $dllSize bytes" + if ($dllSize -gt 2000000) { + Write-Warning "DLL size is larger than expected for Release build (>2MB). Verify this is not a Debug build." + } - name: Upload artifacts (DLL/PDB) if: always() diff --git a/CMakePresets.json b/CMakePresets.json index 8ad2b49..30dd968 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -5,14 +5,14 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": { "type": "STRING", - "value": "Debug" + "value": "Release" } }, "errors": { "deprecated": true }, "hidden": true, - "name": "cmake-dev", + "name": "cmake-release", "warnings": { "deprecated": true, "dev": true @@ -48,17 +48,17 @@ "value": "x64" }, "cacheVariables": { - "CMAKE_CXX_FLAGS": "/EHsc /MP /W4 /WX" + "CMAKE_CXX_FLAGS": "/EHsc /MP /W4" }, "generator": "Visual Studio 17 2022", "inherits": [ - "cmake-dev", + "cmake-release", "vcpkg", "windows" ], - "name": "vs2022-windows", + "name": "vs2022-windows-release", "toolset": "v143" } ], "version": 3 -} \ No newline at end of file +} diff --git a/src/HUDHandler.cpp b/src/HUDHandler.cpp index 3f0848a..94d6ee0 100644 --- a/src/HUDHandler.cpp +++ b/src/HUDHandler.cpp @@ -163,8 +163,13 @@ HUDHandler::EventResult HUDHandler::ProcessEvent(const RE::TESHitEvent* a_event, } if (causeActorHandle && targetActorHandle) { - auto causeActor = causeActorHandle.get()->As(); - auto targetActor = targetActorHandle.get()->As(); + auto causeRef = causeActorHandle.get(); + auto targetRef = targetActorHandle.get(); + if (!causeRef || !targetRef) { + return EventResult::kContinue; + } + auto causeActor = causeRef->As(); + auto targetActor = targetRef->As(); if (causeActor && targetActor) { if (causeActor->IsDead() || targetActor->IsDead()) { @@ -696,8 +701,13 @@ bool HUDHandler::CheckActorForBoss(RE::ObjectRefHandle a_refHandle) return false; } + auto ref = a_refHandle.get(); + if (!ref) { + return false; + } + auto playerCharacter = RE::PlayerCharacter::GetSingleton(); - auto actor = a_refHandle.get()->As(); + auto actor = ref->As(); if (actor && playerCharacter && (actor != playerCharacter)) { // Check whether the target is even alive or hostile first if (actor->IsDead() || (actor->AsActorState()->IsBleedingOut() && actor->IsEssential()) || !actor->IsHostileToActor(playerCharacter)) { @@ -729,8 +739,8 @@ bool HUDHandler::CheckActorForBoss(RE::ObjectRefHandle a_refHandle) // Check current loc refs if (auto currentLocation = playerCharacter->GetPlayerRuntimeData().currentLocation) { - for (auto& ref : currentLocation->specialRefs) { - if (ref.type && Settings::bossLocRefTypes.contains(ref.type) && ref.refData.refID == actor->formID) { + for (auto& locRef : currentLocation->specialRefs) { + if (locRef.type && Settings::bossLocRefTypes.contains(locRef.type) && locRef.refData.refID == actor->formID) { return true; } } diff --git a/src/HUDHandler.h b/src/HUDHandler.h index c20bed8..243016c 100644 --- a/src/HUDHandler.h +++ b/src/HUDHandler.h @@ -23,8 +23,10 @@ class HUDHandler : public RE::BSTEventSink, public RE::BSTEventSink { -private: +public: using EventResult = RE::BSEventNotifyControl; + +private: using TrueHUDMenu = Scaleform::TrueHUDMenu; using BarColorType = ::TRUEHUD_API::BarColorType; using WidgetRemovalMode = TRUEHUD_API::WidgetRemovalMode; diff --git a/src/Widgets/InfoBarBase.cpp b/src/Widgets/InfoBarBase.cpp index f634664..149fc91 100644 --- a/src/Widgets/InfoBarBase.cpp +++ b/src/Widgets/InfoBarBase.cpp @@ -25,6 +25,7 @@ namespace Scaleform if (_widgetState == kPendingHide || _widgetState == kPendingRemoval || _widgetState == kHidden) { _widgetState = kActive; } + break; case WidgetStateMode::kShow: if (_widgetState == kPendingHide || _widgetState == kHidden) { _widgetState = kActive; diff --git a/src/main.cpp b/src/main.cpp index 9c193b0..2a5b6fe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -85,14 +85,18 @@ extern "C" DLLEXPORT bool SKSEAPI SKSEPlugin_Query(const SKSE::QueryInterface* a return true; } -extern "C" DLLEXPORT constinit auto SKSEPlugin_Version = []() { - SKSE::PluginVersionData v; +extern "C" DLLEXPORT constinit auto SKSEPlugin_Version = []() noexcept { + SKSE::PluginVersionData v{}; v.PluginVersion(Plugin::VERSION); v.PluginName(Plugin::NAME); v.AuthorName("Ersh"); v.UsesAddressLibrary(true); - v.CompatibleVersions({ SKSE::RUNTIME_SSE_LATEST }); + // Explicit runtime compatibility list so SKSE will load on 1.5.97 and common AE versions + v.CompatibleVersions({ + SKSE::RUNTIME_SSE_1_5_97, + SKSE::RUNTIME_SSE_LATEST + }); v.HasNoStructUse(true); return v; From 3085bb802a2d9849b98053e366de4d6a89fb6ffb Mon Sep 17 00:00:00 2001 From: Jonathan Picazo Date: Sat, 11 Oct 2025 22:03:53 -0500 Subject: [PATCH 11/11] Fix re-entrancy issues causing infinite loop during widget updates - Add deferred-add mechanism for actor/boss info bars to prevent std::unordered_map insertions during iteration - Introduce _isUpdatingWidgets guard flag to detect update loop - Add _pendingActorInfoBarAdds and _pendingBossInfoBarAdds queues - Implement FlushPendingAdds() to process queued additions after iteration - Move UpdateBossQueue() to execute after boss bar iteration completes - Prevents iterator invalidation that caused crashes/infinite loops Fixes issue where ProcessDelegates() -> AddActorInfoBar() caused re-entrant map mutations during Update() iteration. All unit tests passing. Ready for in-game testing. --- src/Scaleform/TrueHUDMenu.cpp | 52 +++++++++++++++++++++++++++++++++-- src/Scaleform/TrueHUDMenu.h | 7 +++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/Scaleform/TrueHUDMenu.cpp b/src/Scaleform/TrueHUDMenu.cpp index 937ceba..56a5a6d 100644 --- a/src/Scaleform/TrueHUDMenu.cpp +++ b/src/Scaleform/TrueHUDMenu.cpp @@ -68,9 +68,18 @@ namespace Scaleform } bool TrueHUDMenu::AddActorInfoBar(RE::ObjectRefHandle a_actorHandle) - { + { using WidgetStateMode = InfoBarBase::WidgetStateMode; + // Defer additions during Update() to avoid re-entrant mutations of _actorInfoBarMap + if (_isUpdatingWidgets) { + Locker locker(_lock); + if (!HasActorInfoBar(a_actorHandle) && !HasBossInfoBar(a_actorHandle)) { + _pendingActorInfoBarAdds.emplace(a_actorHandle); + } + return false; + } + if (_view && !HasActorInfoBar(a_actorHandle) && !HasBossInfoBar(a_actorHandle)) { Locker locker(_lock); auto widget = std::make_shared(_view, a_actorHandle); @@ -147,6 +156,15 @@ namespace Scaleform { using WidgetStateMode = InfoBarBase::WidgetStateMode; + // Defer additions during Update() to avoid re-entrant mutations of _bossInfoBarMap + if (_isUpdatingWidgets) { + Locker locker(_lock); + if (!HasBossInfoBar(a_actorHandle)) { + _pendingBossInfoBarAdds.emplace(a_actorHandle); + } + return false; + } + if (_view && !HasBossInfoBar(a_actorHandle)) { if (_bossInfoBarMap.size() < Settings::uBossBarMaxCount) { // Add a boss bar @@ -188,7 +206,7 @@ namespace Scaleform } _bossInfoBarMap.erase(it); RefreshBossBarIndexes(index); - UpdateBossQueue(); + // Note: UpdateBossQueue() moved to end of Update() to avoid re-entrant insertion break; } @@ -1368,6 +1386,9 @@ namespace Scaleform RE::GFxValue depthArray; _view->CreateArray(&depthArray); + // Guard: prevent re-entrant additions while iterating widget containers + _isUpdatingWidgets = true; + // actor info bars for (auto widget_it = _actorInfoBarMap.begin(), next_widget_it = widget_it; widget_it != _actorInfoBarMap.end(); widget_it = next_widget_it) { ++next_widget_it; @@ -1407,6 +1428,9 @@ namespace Scaleform // add to depths array AddToDepthsArray(widget, static_cast(TrueHUDWidgetType::kBossBar), depthArray); } + + // Process boss queue after iteration completes to avoid re-entrant insertion + UpdateBossQueue(); if (_shoutIndicator) { _shoutIndicator->ProcessDelegates(); @@ -1481,6 +1505,10 @@ namespace Scaleform // sort widget depths _view->Invoke("_root.TrueHUD.SortDepths", nullptr, &depthArray, 1); + // Flush any adds that were requested while updating widgets + _isUpdatingWidgets = false; + FlushPendingAdds(); + UpdateColors(); UpdateDebugDraw(a_deltaTime); @@ -1680,6 +1708,26 @@ namespace Scaleform a_array.PushBack(data); } + void TrueHUDMenu::FlushPendingAdds() + { + // Move pending sets under lock, then process without holding the lock + std::unordered_set actorAdds; + std::unordered_set bossAdds; + { + Locker locker(_lock); + actorAdds.swap(_pendingActorInfoBarAdds); + bossAdds.swap(_pendingBossInfoBarAdds); + } + + for (auto& h : actorAdds) { + // Re-check to avoid duplicates; AddActorInfoBar will guard anyway + AddActorInfoBar(h); + } + for (auto& h : bossAdds) { + AddBossInfoBar(h); + } + } + void TrueHUDMenu::UpdateDebugDraw(float a_deltaTime) { if (!_view) { diff --git a/src/Scaleform/TrueHUDMenu.h b/src/Scaleform/TrueHUDMenu.h index df19357..55d9655 100644 --- a/src/Scaleform/TrueHUDMenu.h +++ b/src/Scaleform/TrueHUDMenu.h @@ -10,6 +10,7 @@ #include "Offsets.h" #include #include +#include namespace std { @@ -318,6 +319,7 @@ namespace Scaleform void AddToDepthsArray(std::shared_ptr a_widget, uint32_t a_widgetType, RE::GFxValue& a_array); void UpdateDebugDraw(float a_deltaTime); + void FlushPendingAdds(); void DrawLine2D(RE::NiPoint2& a_start, RE::NiPoint2& a_end, uint32_t a_color, float a_thickness); void DrawPoint2D(RE::NiPoint2& a_position, uint32_t a_color, float a_size); @@ -362,6 +364,11 @@ namespace Scaleform std::unordered_map> _colorOverrides; std::unordered_set _pendingColorChanges; + // Re-entrancy guard and pending-add queues to avoid mutating containers during Update() + bool _isUpdatingWidgets = false; + std::unordered_set _pendingActorInfoBarAdds; + std::unordered_set _pendingBossInfoBarAdds; + std::vector> _linesToDraw; std::vector> _pointsToDraw;