From c6c7493a4cc6aa2a393095c8f5f38796fc6e196e Mon Sep 17 00:00:00 2001 From: DJLegends Date: Wed, 16 Sep 2026 05:36:46 -0500 Subject: [PATCH 1/8] Add native DebugMessageBox helper --- include/RE/IDs.h | 12 +++ include/RE/M/Misc.h | 4 + src/RE/M/Misc.cpp | 70 ++++++++++++++++ tests/message_box/.gitignore | 3 + tests/message_box/README.md | 40 +++++++++ tests/message_box/main.cpp | 154 +++++++++++++++++++++++++++++++++++ tests/message_box/xmake.lua | 9 ++ 7 files changed, 292 insertions(+) create mode 100644 src/RE/M/Misc.cpp create mode 100644 tests/message_box/.gitignore create mode 100644 tests/message_box/README.md create mode 100644 tests/message_box/main.cpp create mode 100644 tests/message_box/xmake.lua diff --git a/include/RE/IDs.h b/include/RE/IDs.h index ec67782b..e30367ef 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -420,6 +420,11 @@ namespace RE::ID inline constexpr REL::ID GetEntryW{ 1186743 }; } + namespace BSStringT + { + inline constexpr REL::ID Assign{ 36345 }; + } + namespace BSStorage { inline constexpr REL::ID FlushStreamBuffer{ 0 }; // 72433 @@ -1282,6 +1287,13 @@ namespace RE::ID inline constexpr REL::ID GetEventSource{ 0 }; // 167118 } + namespace MessageMenuManager + { + inline constexpr REL::ID Singleton{ 938019 }; + inline constexpr REL::ID CreateMessageBox{ 114231 }; + inline constexpr REL::ID DestroyMessageBoxParams{ 43998 }; + } + namespace Misc { inline constexpr REL::ID DebugNotification{ 0 }; // 138728 diff --git a/include/RE/M/Misc.h b/include/RE/M/Misc.h index 4039df5a..b20e72d8 100644 --- a/include/RE/M/Misc.h +++ b/include/RE/M/Misc.h @@ -9,6 +9,10 @@ namespace RE { + // Queues a DEBUG message with the game's localized OK button. Call on the + // game thread after data loads. Does nothing if the message manager is absent. + void DebugMessageBox(const char* a_message); + inline void DebugNotification(const char* a_notification, const char* a_soundToPlay = nullptr, bool a_cancelIfAlreadyQueued = true, bool a_arg4 = false) { using func_t = decltype(&DebugNotification); diff --git a/src/RE/M/Misc.cpp b/src/RE/M/Misc.cpp new file mode 100644 index 00000000..72e4f898 --- /dev/null +++ b/src/RE/M/Misc.cpp @@ -0,0 +1,70 @@ +#include "RE/M/Misc.h" + +namespace RE +{ + class IMessageBoxCallback; + class MessageMenuManager; + + namespace + { + // The native request uses BSStringT. + // Its inline storage differs from the BSStringT currently exposed by this + // library. Keep the ABI private and let the engine assign and destroy it. + struct MessageBoxString + { + std::byte storage[0xC]{}; // 00: inline characters or owned pointer + std::uint16_t capacity{ 1 }; // 0C: includes the terminator + std::uint16_t length{}; // 0E + }; + static_assert(sizeof(MessageBoxString) == 0x10); + static_assert(offsetof(MessageBoxString, capacity) == 0xC); + static_assert(offsetof(MessageBoxString, length) == 0xE); + + struct MessageBoxParams + { + MessageBoxParams() = default; + MessageBoxParams(const MessageBoxParams&) = delete; + MessageBoxParams& operator=(const MessageBoxParams&) = delete; + + ~MessageBoxParams() + { + using func_t = void (*)(MessageBoxParams*); + static REL::Relocation func{ ID::MessageMenuManager::DestroyMessageBoxParams }; + func(this); + } + + IMessageBoxCallback* callback{}; // 00 + MessageBoxString header; // 08 + MessageBoxString body; // 18 + MessageBoxString unk28; // 28 + std::uint32_t warningContext{ 5 }; // 38: script + }; + static_assert(sizeof(MessageBoxParams) == 0x40); + static_assert(offsetof(MessageBoxParams, callback) == 0x00); + static_assert(offsetof(MessageBoxParams, header) == 0x08); + static_assert(offsetof(MessageBoxParams, body) == 0x18); + static_assert(offsetof(MessageBoxParams, unk28) == 0x28); + static_assert(offsetof(MessageBoxParams, warningContext) == 0x38); + } + + void DebugMessageBox(const char* a_message) + { + static REL::Relocation singleton{ ID::MessageMenuManager::Singleton }; + auto manager = *singleton; + if (!manager) { + return; + } + + MessageBoxParams params; + using assign_t = bool (*)(MessageBoxString*, const char*, std::size_t); + static REL::Relocation assign{ ID::BSStringT::Assign }; + assign(¶ms.header, "DEBUG", 0); + assign(¶ms.body, a_message, 0); + + // This overload supplies the localized OK button and moves the owned + // strings/callback into its request. The destructor handles either state. + using create_t = void (*)(MessageMenuManager*, MessageBoxParams*, bool); + static REL::Relocation create{ ID::MessageMenuManager::CreateMessageBox }; + create(manager, ¶ms, false); + } +} diff --git a/tests/message_box/.gitignore b/tests/message_box/.gitignore new file mode 100644 index 00000000..de886c4f --- /dev/null +++ b/tests/message_box/.gitignore @@ -0,0 +1,3 @@ +/build/ +/.xmake/ +/vsxmake*/ diff --git a/tests/message_box/README.md b/tests/message_box/README.md new file mode 100644 index 00000000..55674f2e --- /dev/null +++ b/tests/message_box/README.md @@ -0,0 +1,40 @@ +# Native debug message box + +`RE::DebugMessageBox(const char*)` queues a message with the `DEBUG` title and the game's localized OK button. A null string is treated as empty. It has no callback or custom-button API. Call it on the game thread after game data has loaded, for example from an SFSE task scheduled upon `kPostDataLoad`. + +## Evidence + +Investigated with Ghidra MCP against Steam Starfield **1.16.244.0**, image base `0x140000000`. Unpacked executable SHA-256: `a100fcf3982047e29ae79d5f04a4cb0011641e08292f3431ccb9ac81fc63d3ac`. + +The `Debug` / `MessageBox` Papyrus binding in `0x142004940` registers callback `0x1420035D0`. That callback constructs a native message request and calls the one-button manager overload. This implementation recreates that native request and calls the manager directly; it never invokes the Papyrus callback or invents VM arguments. + +| Purpose | Address Library ID | VA in 1.16.244 | +| --- | ---: | --- | +| Manager singleton pointer | 938019 | `0x1461DE720` | +| Native one-button Create | 114231 | `0x141ED62B0` | +| Native string assignment | 36345 | `0x1402D31D0` | +| Request destructor | 43998 | `0x1404DF060` | + +The IDs were matched against `versionlib-1-16-244-0.bin`. Addresses above are research evidence only; implementation uses named `REL::ID` constants. + +The request consists of a callback smart pointer at `0x00`, three 16-byte strings at `0x08`, `0x18`, and `0x28`, and a 32-bit warning category at `0x38`. Debug.MessageBox uses a null callback, `DEBUG`, the message body, an empty third string, and category 5. The meaning of the third string has not been established, so it remains unnamed. The request occupies an eight-byte-aligned `0x40` bytes. + +String assignment accesses a 12-byte inline buffer / heap pointer at `0x00`, capacity at `0x0C`, and length at `0x0E`. Capacity includes the terminator; values above 12 select heap storage. Reflection function `0x1427C9950`, associated with `BSReflection::BSStringType>`, uses the same assignment and layout. The existing public `BSStringT` has a different layout, so this contribution keeps the verified representation private and uses native assignment and destruction. + +Create at `0x141ED62B0` builds one localized OK button, moves the callback and three strings out of its input, and submits the expanded request to `0x141ED53A0`. Its third argument becomes the uniqueness flag at expanded-request offset `0x59`; Debug.MessageBox passes false. The move helper at `0x140392E00` resets source capacity to 1 and length to zero. The destructor at `0x1404DF060` frees only strings with capacity above 12 and releases any callback reference. These observations establish safe cleanup after the native call. The input request must not be copied. + +## Offline tests + +From the repository root: + +```powershell +xmake f -P tests/message_box -m releasedbg -y +xmake -P tests/message_box message-box-tests +xmake run -P tests/message_box message-box-tests +``` + +The tests link the actual library implementation and `REL::Relocation`. Only the address database is substituted, binding the four IDs to independent native-function stand-ins. They check request offsets and contents, empty/null/UTF-8/long strings, the 11/12-character inline boundary, missing-manager behavior, and cleanup with and without ownership transfer. These tests validate request construction and cleanup calls, not execution of the real engine allocator or UI. + +## Runtime validation status + +The native implementation has **not yet been tested in game**. The previously working example plugin called the Papyrus wrapper and is not runtime validation of this change. Test the native helper in Starfield 1.16.244 before marking the PR ready. Other executable versions have not been investigated; Address Library indirection alone does not establish ABI compatibility. diff --git a/tests/message_box/main.cpp b/tests/message_box/main.cpp new file mode 100644 index 00000000..e2f31056 --- /dev/null +++ b/tests/message_box/main.cpp @@ -0,0 +1,154 @@ +#include "RE/Starfield.h" + +#include +#include +#include + +namespace +{ + int managerStorage; + void* manager = &managerStorage; + std::unordered_set allocations; + std::string expected; + bool consume = true; + std::size_t creates = 0; + std::size_t destroys = 0; + std::size_t assignments = 0; + + void Check(bool a_condition, const char* a_message) + { + if (!a_condition) { + std::cerr << a_message << '\n'; + std::exit(1); + } + } + + template + T Read(const void* a_data, std::size_t a_offset) + { + T value; + std::memcpy(&value, static_cast(a_data) + a_offset, sizeof(T)); + return value; + } + + template + void Write(void* a_data, std::size_t a_offset, T a_value) + { + std::memcpy(static_cast(a_data) + a_offset, &a_value, sizeof(T)); + } + + const char* StringData(const void* a_string) + { + return Read(a_string, 0xC) > 12 ? Read(a_string, 0) : static_cast(a_string); + } + + // Independent stand-ins for the engine ABI observed in Starfield 1.16.244. + bool Assign(void* a_string, const char* a_text, std::size_t a_length) + { + ++assignments; + Check(a_length == 0, "String assignment must use the complete NUL-terminated input"); + Check(Read(a_string, 0xC) == 1, "String capacity must start at one"); + Check(Read(a_string, 0xE) == 0, "String length must start at zero"); + const auto text = a_text ? a_text : ""; + const auto size = std::strlen(text); + char* buffer = static_cast(a_string); + if (size + 1 > 12) { + buffer = new char[size + 1]; + allocations.insert(buffer); + Write(a_string, 0, buffer); + } + std::memcpy(buffer, text, size + 1); + Write(a_string, 0xC, static_cast(size + 1)); + Write(a_string, 0xE, static_cast(size)); + return size != 0; + } + + void ReleaseString(void* a_string) + { + if (Read(a_string, 0xC) > 12) { + auto buffer = Read(a_string, 0); + Check(allocations.erase(buffer) == 1, "Invalid or repeated string release"); + delete[] buffer; + } + std::memset(a_string, 0, 16); + Write(a_string, 0xC, std::uint16_t{ 1 }); + } + + void Destroy(void* a_request) + { + ++destroys; + Check(Read(a_request, 0) == nullptr, "Debug message must not install a callback"); + for (const auto offset : { 0x28, 0x18, 0x08 }) { + ReleaseString(static_cast(a_request) + offset); + } + } + + void Create(void* a_manager, void* a_request, bool a_ensureUnique) + { + ++creates; + Check(a_manager == &managerStorage, "Wrong manager pointer"); + Check(!a_ensureUnique, "Debug.MessageBox permits repeated messages"); + Check(Read(a_request, 0) == nullptr, "Unexpected callback"); + Check(Read(a_request, 0x38) == 5, "Wrong script warning category"); + Check(std::string_view(StringData(static_cast(a_request) + 0x08)) == "DEBUG", "Wrong title or string layout"); + Check(std::string_view(StringData(static_cast(a_request) + 0x18)) == expected, "Message differs from caller input"); + Check(std::string_view(StringData(static_cast(a_request) + 0x28)).empty(), "Third string must be empty"); + if (consume) { + // The native Create moves these buffers; simulate ownership leaving the request. + for (const auto offset : { 0x08, 0x18, 0x28 }) { + ReleaseString(static_cast(a_request) + offset); + } + } + } +} + +// Link-time substitution of the address database only. The test calls the real +// library implementation and real REL::Relocation; no game or versionlib is loaded. +namespace REL +{ + IDDB::IDDB() = default; + + std::uint64_t IDDB::offset(std::uint64_t a_id) const + { + std::uintptr_t address = 0; + switch (a_id) { + case 36345: + address = reinterpret_cast(&Assign); + break; + case 43998: + address = reinterpret_cast(&Destroy); + break; + case 114231: + address = reinterpret_cast(&Create); + break; + case 938019: + address = reinterpret_cast(&manager); + break; + default: + Check(false, "Unexpected relocation: the Papyrus callback must not be used"); + } + return address - REX::FModule::GetExecutingModule().GetBaseAddress(); + } +} + +int main() +{ + manager = nullptr; + RE::DebugMessageBox("Not ready"); + Check(creates == 0 && destroys == 0 && assignments == 0, "Missing manager should do nothing"); + manager = &managerStorage; + const std::string messages[]{ "", "hello", std::string(11, 'a'), std::string(12, 'b'), std::string(4096, 'c'), "A line\nwith UTF-8: \xE2\x98\x85" }; + for (const bool transfer : { false, true }) { + consume = transfer; + for (const auto& message : messages) { + expected = message; + RE::DebugMessageBox(message.c_str()); + Check(allocations.empty(), "Request leaked a string allocation"); + } + expected.clear(); + RE::DebugMessageBox(nullptr); + Check(allocations.empty(), "Null input leaked an allocation"); + } + Check(creates == 14 && destroys == 14, "Each request must be created and destroyed exactly once"); + std::cout << "PASS: 14 requests, inline/heap strings, null input, native ownership, and missing manager\n"; +} diff --git a/tests/message_box/xmake.lua b/tests/message_box/xmake.lua new file mode 100644 index 00000000..79ced20a --- /dev/null +++ b/tests/message_box/xmake.lua @@ -0,0 +1,9 @@ +includes("../..") +set_languages("c++23") +add_rules("mode.debug", "mode.releasedbg") + +target("message-box-tests") + set_kind("binary") + set_default(true) + add_deps("commonlibsf") + add_files("main.cpp") From a28a52f61b698909494b388d47ac897640d58bae Mon Sep 17 00:00:00 2001 From: DJLegends Date: Mon, 28 Sep 2026 15:10:34 -0500 Subject: [PATCH 2/8] Document successful native message-box in-game test --- tests/message_box/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/message_box/README.md b/tests/message_box/README.md index 55674f2e..74f4bfc6 100644 --- a/tests/message_box/README.md +++ b/tests/message_box/README.md @@ -37,4 +37,6 @@ The tests link the actual library implementation and `REL::Relocation`. Only the ## Runtime validation status -The native implementation has **not yet been tested in game**. The previously working example plugin called the Papyrus wrapper and is not runtime validation of this change. Test the native helper in Starfield 1.16.244 before marking the PR ready. Other executable versions have not been investigated; Address Library indirection alone does not establish ABI compatibility. +The author tested the native implementation in **Starfield 1.16.244** on September 28, 2026, using an SFSE consumer built against this contribution. The startup message appeared with the expected text and OK button; the author supplied a screenshot and confirmed that clicking OK closed it normally. This consumer calls `RE::DebugMessageBox` directly after `kPostDataLoad` through an SFSE game-thread task. + +The tested DLL's SHA-256 is `feedc7e96950a0949d8cd99a56b317f76903ed71e63f80159c1be1ca5c66bcac`. This establishes basic in-game display and dismissal for that build, not exhaustive memory/lifetime testing. Other executable versions have not been investigated; Address Library indirection alone does not establish ABI compatibility. From 8bc999d55f59203bc867edef8b2f30f5fd84a3df Mon Sep 17 00:00:00 2001 From: DJLegends Date: Mon, 28 Sep 2026 16:27:29 -0500 Subject: [PATCH 3/8] Keep message-box tests local and ignored --- .gitignore | 3 + tests/message_box/.gitignore | 3 - tests/message_box/README.md | 42 ---------- tests/message_box/main.cpp | 154 ----------------------------------- tests/message_box/xmake.lua | 9 -- 5 files changed, 3 insertions(+), 208 deletions(-) delete mode 100644 tests/message_box/.gitignore delete mode 100644 tests/message_box/README.md delete mode 100644 tests/message_box/main.cpp delete mode 100644 tests/message_box/xmake.lua diff --git a/.gitignore b/.gitignore index ee8888d2..24d8c655 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ graph_info.json .idea *ReSharper* + +# Local message-box validation harness +/tests/message_box/ diff --git a/tests/message_box/.gitignore b/tests/message_box/.gitignore deleted file mode 100644 index de886c4f..00000000 --- a/tests/message_box/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/build/ -/.xmake/ -/vsxmake*/ diff --git a/tests/message_box/README.md b/tests/message_box/README.md deleted file mode 100644 index 74f4bfc6..00000000 --- a/tests/message_box/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Native debug message box - -`RE::DebugMessageBox(const char*)` queues a message with the `DEBUG` title and the game's localized OK button. A null string is treated as empty. It has no callback or custom-button API. Call it on the game thread after game data has loaded, for example from an SFSE task scheduled upon `kPostDataLoad`. - -## Evidence - -Investigated with Ghidra MCP against Steam Starfield **1.16.244.0**, image base `0x140000000`. Unpacked executable SHA-256: `a100fcf3982047e29ae79d5f04a4cb0011641e08292f3431ccb9ac81fc63d3ac`. - -The `Debug` / `MessageBox` Papyrus binding in `0x142004940` registers callback `0x1420035D0`. That callback constructs a native message request and calls the one-button manager overload. This implementation recreates that native request and calls the manager directly; it never invokes the Papyrus callback or invents VM arguments. - -| Purpose | Address Library ID | VA in 1.16.244 | -| --- | ---: | --- | -| Manager singleton pointer | 938019 | `0x1461DE720` | -| Native one-button Create | 114231 | `0x141ED62B0` | -| Native string assignment | 36345 | `0x1402D31D0` | -| Request destructor | 43998 | `0x1404DF060` | - -The IDs were matched against `versionlib-1-16-244-0.bin`. Addresses above are research evidence only; implementation uses named `REL::ID` constants. - -The request consists of a callback smart pointer at `0x00`, three 16-byte strings at `0x08`, `0x18`, and `0x28`, and a 32-bit warning category at `0x38`. Debug.MessageBox uses a null callback, `DEBUG`, the message body, an empty third string, and category 5. The meaning of the third string has not been established, so it remains unnamed. The request occupies an eight-byte-aligned `0x40` bytes. - -String assignment accesses a 12-byte inline buffer / heap pointer at `0x00`, capacity at `0x0C`, and length at `0x0E`. Capacity includes the terminator; values above 12 select heap storage. Reflection function `0x1427C9950`, associated with `BSReflection::BSStringType>`, uses the same assignment and layout. The existing public `BSStringT` has a different layout, so this contribution keeps the verified representation private and uses native assignment and destruction. - -Create at `0x141ED62B0` builds one localized OK button, moves the callback and three strings out of its input, and submits the expanded request to `0x141ED53A0`. Its third argument becomes the uniqueness flag at expanded-request offset `0x59`; Debug.MessageBox passes false. The move helper at `0x140392E00` resets source capacity to 1 and length to zero. The destructor at `0x1404DF060` frees only strings with capacity above 12 and releases any callback reference. These observations establish safe cleanup after the native call. The input request must not be copied. - -## Offline tests - -From the repository root: - -```powershell -xmake f -P tests/message_box -m releasedbg -y -xmake -P tests/message_box message-box-tests -xmake run -P tests/message_box message-box-tests -``` - -The tests link the actual library implementation and `REL::Relocation`. Only the address database is substituted, binding the four IDs to independent native-function stand-ins. They check request offsets and contents, empty/null/UTF-8/long strings, the 11/12-character inline boundary, missing-manager behavior, and cleanup with and without ownership transfer. These tests validate request construction and cleanup calls, not execution of the real engine allocator or UI. - -## Runtime validation status - -The author tested the native implementation in **Starfield 1.16.244** on September 28, 2026, using an SFSE consumer built against this contribution. The startup message appeared with the expected text and OK button; the author supplied a screenshot and confirmed that clicking OK closed it normally. This consumer calls `RE::DebugMessageBox` directly after `kPostDataLoad` through an SFSE game-thread task. - -The tested DLL's SHA-256 is `feedc7e96950a0949d8cd99a56b317f76903ed71e63f80159c1be1ca5c66bcac`. This establishes basic in-game display and dismissal for that build, not exhaustive memory/lifetime testing. Other executable versions have not been investigated; Address Library indirection alone does not establish ABI compatibility. diff --git a/tests/message_box/main.cpp b/tests/message_box/main.cpp deleted file mode 100644 index e2f31056..00000000 --- a/tests/message_box/main.cpp +++ /dev/null @@ -1,154 +0,0 @@ -#include "RE/Starfield.h" - -#include -#include -#include - -namespace -{ - int managerStorage; - void* manager = &managerStorage; - std::unordered_set allocations; - std::string expected; - bool consume = true; - std::size_t creates = 0; - std::size_t destroys = 0; - std::size_t assignments = 0; - - void Check(bool a_condition, const char* a_message) - { - if (!a_condition) { - std::cerr << a_message << '\n'; - std::exit(1); - } - } - - template - T Read(const void* a_data, std::size_t a_offset) - { - T value; - std::memcpy(&value, static_cast(a_data) + a_offset, sizeof(T)); - return value; - } - - template - void Write(void* a_data, std::size_t a_offset, T a_value) - { - std::memcpy(static_cast(a_data) + a_offset, &a_value, sizeof(T)); - } - - const char* StringData(const void* a_string) - { - return Read(a_string, 0xC) > 12 ? Read(a_string, 0) : static_cast(a_string); - } - - // Independent stand-ins for the engine ABI observed in Starfield 1.16.244. - bool Assign(void* a_string, const char* a_text, std::size_t a_length) - { - ++assignments; - Check(a_length == 0, "String assignment must use the complete NUL-terminated input"); - Check(Read(a_string, 0xC) == 1, "String capacity must start at one"); - Check(Read(a_string, 0xE) == 0, "String length must start at zero"); - const auto text = a_text ? a_text : ""; - const auto size = std::strlen(text); - char* buffer = static_cast(a_string); - if (size + 1 > 12) { - buffer = new char[size + 1]; - allocations.insert(buffer); - Write(a_string, 0, buffer); - } - std::memcpy(buffer, text, size + 1); - Write(a_string, 0xC, static_cast(size + 1)); - Write(a_string, 0xE, static_cast(size)); - return size != 0; - } - - void ReleaseString(void* a_string) - { - if (Read(a_string, 0xC) > 12) { - auto buffer = Read(a_string, 0); - Check(allocations.erase(buffer) == 1, "Invalid or repeated string release"); - delete[] buffer; - } - std::memset(a_string, 0, 16); - Write(a_string, 0xC, std::uint16_t{ 1 }); - } - - void Destroy(void* a_request) - { - ++destroys; - Check(Read(a_request, 0) == nullptr, "Debug message must not install a callback"); - for (const auto offset : { 0x28, 0x18, 0x08 }) { - ReleaseString(static_cast(a_request) + offset); - } - } - - void Create(void* a_manager, void* a_request, bool a_ensureUnique) - { - ++creates; - Check(a_manager == &managerStorage, "Wrong manager pointer"); - Check(!a_ensureUnique, "Debug.MessageBox permits repeated messages"); - Check(Read(a_request, 0) == nullptr, "Unexpected callback"); - Check(Read(a_request, 0x38) == 5, "Wrong script warning category"); - Check(std::string_view(StringData(static_cast(a_request) + 0x08)) == "DEBUG", "Wrong title or string layout"); - Check(std::string_view(StringData(static_cast(a_request) + 0x18)) == expected, "Message differs from caller input"); - Check(std::string_view(StringData(static_cast(a_request) + 0x28)).empty(), "Third string must be empty"); - if (consume) { - // The native Create moves these buffers; simulate ownership leaving the request. - for (const auto offset : { 0x08, 0x18, 0x28 }) { - ReleaseString(static_cast(a_request) + offset); - } - } - } -} - -// Link-time substitution of the address database only. The test calls the real -// library implementation and real REL::Relocation; no game or versionlib is loaded. -namespace REL -{ - IDDB::IDDB() = default; - - std::uint64_t IDDB::offset(std::uint64_t a_id) const - { - std::uintptr_t address = 0; - switch (a_id) { - case 36345: - address = reinterpret_cast(&Assign); - break; - case 43998: - address = reinterpret_cast(&Destroy); - break; - case 114231: - address = reinterpret_cast(&Create); - break; - case 938019: - address = reinterpret_cast(&manager); - break; - default: - Check(false, "Unexpected relocation: the Papyrus callback must not be used"); - } - return address - REX::FModule::GetExecutingModule().GetBaseAddress(); - } -} - -int main() -{ - manager = nullptr; - RE::DebugMessageBox("Not ready"); - Check(creates == 0 && destroys == 0 && assignments == 0, "Missing manager should do nothing"); - manager = &managerStorage; - const std::string messages[]{ "", "hello", std::string(11, 'a'), std::string(12, 'b'), std::string(4096, 'c'), "A line\nwith UTF-8: \xE2\x98\x85" }; - for (const bool transfer : { false, true }) { - consume = transfer; - for (const auto& message : messages) { - expected = message; - RE::DebugMessageBox(message.c_str()); - Check(allocations.empty(), "Request leaked a string allocation"); - } - expected.clear(); - RE::DebugMessageBox(nullptr); - Check(allocations.empty(), "Null input leaked an allocation"); - } - Check(creates == 14 && destroys == 14, "Each request must be created and destroyed exactly once"); - std::cout << "PASS: 14 requests, inline/heap strings, null input, native ownership, and missing manager\n"; -} diff --git a/tests/message_box/xmake.lua b/tests/message_box/xmake.lua deleted file mode 100644 index 79ced20a..00000000 --- a/tests/message_box/xmake.lua +++ /dev/null @@ -1,9 +0,0 @@ -includes("../..") -set_languages("c++23") -add_rules("mode.debug", "mode.releasedbg") - -target("message-box-tests") - set_kind("binary") - set_default(true) - add_deps("commonlibsf") - add_files("main.cpp") From dbeacc0779fca43b5c4803901f683535901ddf67 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:38:23 -0400 Subject: [PATCH 4/8] Correct BSStringT layout/signature --- include/RE/B/BSStringT.h | 100 +++++++++++++++++++-------------------- include/RE/IDs.h | 5 ++ 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/include/RE/B/BSStringT.h b/include/RE/B/BSStringT.h index a98a9985..95ef0a91 100644 --- a/include/RE/B/BSStringT.h +++ b/include/RE/B/BSStringT.h @@ -1,5 +1,7 @@ #pragma once +#include "RE/M/MemoryManager.h" + namespace RE { template @@ -33,24 +35,15 @@ namespace RE using traits_type = std::char_traits; constexpr BSStringT() noexcept = default; - - BSStringT(const BSStringT& a_rhs) { copy_from(a_rhs); } - - BSStringT(BSStringT&& a_rhs) noexcept - { - if constexpr (allocator_type::propagate_on_container_move_assignment::value) { - move_from(std::move(a_rhs)); - } else { - copy_from(a_rhs); - } - } - + BSStringT(const_pointer a_string) { Assign(a_string); } + BSStringT(const BSStringT& a_rhs) { Assign(a_rhs.c_str()); } + BSStringT(BSStringT&& a_rhs) noexcept { move_from(std::move(a_rhs)); } ~BSStringT() { release(); } BSStringT& operator=(const BSStringT& a_rhs) { if (this != std::addressof(a_rhs)) { - copy_from(a_rhs); + Assign(a_rhs.c_str()); } return *this; } @@ -58,64 +51,69 @@ namespace RE BSStringT& operator=(BSStringT&& a_rhs) noexcept { if (this != std::addressof(a_rhs)) { - if constexpr (allocator_type::propagate_on_container_move_assignment::value) { - move_from(std::move(a_rhs)); - } else { - copy_from(a_rhs); - } + release(); + move_from(std::move(a_rhs)); } return *this; } - [[nodiscard]] const_pointer data() const noexcept { return _data; } - [[nodiscard]] pointer data() noexcept { return _data; } - - [[nodiscard]] const_pointer c_str() const noexcept { return data(); } + BSStringT& operator=(const_pointer a_string) + { + Assign(a_string); + return *this; + } - [[nodiscard]] operator std::basic_string_view() const noexcept { return { data(), size() }; } + bool Assign(const_pointer a_string, std::size_t a_reserveLength = 0) + { + using func_t = bool (*)(BSStringT*, const_pointer, std::size_t); + static REL::Relocation func{ ID::BSStringT::Assign }; + return func(this, a_string, a_reserveLength); + } - [[nodiscard]] bool empty() const noexcept { return size() == 0; } + [[nodiscard]] const_pointer data() const noexcept + { + if (_capacity <= kInlineCapacity) { + return _storage; + } + pointer result; + std::memcpy(&result, _storage, sizeof(result)); + return result; + } + [[nodiscard]] pointer data() noexcept { return const_cast(std::as_const(*this).data()); } + [[nodiscard]] const_pointer c_str() const noexcept { return data(); } + [[nodiscard]] operator std::basic_string_view() const noexcept { return { data(), size() }; } + [[nodiscard]] bool empty() const noexcept { return size() == 0; } [[nodiscard]] size_type size() const noexcept { return length(); } - [[nodiscard]] size_type length() const noexcept { return _size == N ? static_cast(traits_type::length(c_str())) : _size; } - + [[nodiscard]] size_type length() const noexcept { return _size == std::numeric_limits::max() ? static_cast(traits_type::length(c_str())) : _size; } [[nodiscard]] size_type capacity() const noexcept { return _capacity; } private: - void release() - { - allocator_type::deallocate(_data, _capacity); - _data = nullptr; - _size = 0; - _capacity = 0; - } + static constexpr size_type kInlineCapacity = 0xC; - void copy_from(const BSStringT& a_rhs) + void release() { - release(); - - _size = a_rhs._size; - _capacity = a_rhs._capacity; - _data = allocator_type::allocate(_capacity); - - std::uninitialized_copy_n(a_rhs._data, _size, _data); + if (_capacity > kInlineCapacity) { + allocator_type::deallocate(data(), _capacity); + } } - void move_from(BSStringT&& a_rhs) + void move_from(BSStringT&& a_rhs) noexcept { - release(); - - _data = a_rhs._data; + std::memcpy(_storage, a_rhs._storage, sizeof(_storage)); _size = a_rhs._size; _capacity = a_rhs._capacity; - - a_rhs._data = nullptr; + a_rhs._storage[0] = '\0'; a_rhs._size = 0; - a_rhs._capacity = 0; + a_rhs._capacity = 1; } - pointer _data{}; // ?? - size_type _size{}; // ?? - size_type _capacity{}; // ?? + value_type _storage[kInlineCapacity]{}; // 00 + size_type _capacity{ 1 }; // 0C (includes the terminator) + size_type _size{}; // 0E }; + + using BSString = BSStringT; + static_assert(sizeof(BSString) == 0x10); + static_assert(alignof(BSString) == 0x2); } diff --git a/include/RE/IDs.h b/include/RE/IDs.h index 63041c57..8cb07f6a 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -430,6 +430,11 @@ namespace RE::ID inline constexpr REL::ID Unlock{ 35630 }; } + namespace BSStringT + { + inline constexpr REL::ID Assign{ 36345 }; + } + namespace BSStringPool { namespace Entry From eb3e9e464df64176c3016bf368499e62700fb407 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:39:17 -0400 Subject: [PATCH 5/8] Fix BGSPackIn and ScriptVariable byte alignments --- include/RE/B/BGSPackIn.h | 21 +++++++++++---------- include/RE/S/Script.h | 7 ++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/include/RE/B/BGSPackIn.h b/include/RE/B/BGSPackIn.h index 96be27fa..fab66329 100644 --- a/include/RE/B/BGSPackIn.h +++ b/include/RE/B/BGSPackIn.h @@ -15,11 +15,11 @@ namespace RE class BGSPackIn : public TESBoundObject, // 000 - public BGSKeywordForm, // 0E0 - public BGSPropertySheet, // 110 - public BGSPreviewTransform, // 120 - public BGSForcedLocRefType, // 168 - public BGSNativeTerminalForm // 180 + public BGSKeywordForm, // 0E8 + public BGSPropertySheet, // 118 + public BGSPreviewTransform, // 128 + public BGSForcedLocRefType, // 170 + public BGSNativeTerminalForm // 188 { public: SF_RTTI_VTABLE(BGSPackIn); @@ -28,11 +28,12 @@ namespace RE ~BGSPackIn() override; // 00 // members - TESObjectCELL* cell; // 190 - std::uint32_t flags; // 198 - BSStringT filter; // 19C - BSTArray materialSwaps; // 1B0 - std::uint32_t unk1C0; // 1C0 + TESObjectCELL* cell; // 198 + std::uint32_t flags; // 1A0 + BSString filter; // 1A4 + BSTArray materialSwaps; // 1B8 + std::uint32_t unk1C0; // 1C8 }; + static_assert(offsetof(BGSPackIn, filter) == 0x1A4); static_assert(sizeof(BGSPackIn) == 0x1D0); } diff --git a/include/RE/S/Script.h b/include/RE/S/Script.h index d081aa9d..817646f1 100644 --- a/include/RE/S/Script.h +++ b/include/RE/S/Script.h @@ -173,10 +173,11 @@ namespace RE struct ScriptVariable { // members - SCRIPT_LOCAL data; // 00 - BSStringT name; // 10 + SCRIPT_LOCAL data; // 00 + BSString name; // 0C }; - static_assert(sizeof(ScriptVariable) == 0x20); + static_assert(offsetof(ScriptVariable, name) == 0x0C); + static_assert(sizeof(ScriptVariable) == 0x1C); class Script { From 6ea01e972117ece60577a26641c3ed3cca35cb5e Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:39:30 -0400 Subject: [PATCH 6/8] Remove TextData from TESFile --- include/RE/T/TESFile.h | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/include/RE/T/TESFile.h b/include/RE/T/TESFile.h index 4c8dd5b7..a8287fe8 100644 --- a/include/RE/T/TESFile.h +++ b/include/RE/T/TESFile.h @@ -1,5 +1,7 @@ #pragma once +#include "RE/B/BSStringT.h" + namespace RE { class TESFile @@ -27,23 +29,6 @@ namespace RE }; static_assert(sizeof(TypedIndex) == 0x4); - struct TextData - { - private: - static constexpr inline std::uint16_t kRawCapacity = 12; - // members - const char _data[kRawCapacity]; // 00 - std::uint16_t _bufferSize; // 0C - std::uint16_t _size; // 0E - public: - const char* GetText() const - { - const char* result = _data; - return _size < kRawCapacity ? result : *std::bit_cast(result); - } - }; - static_assert(sizeof(TextData) == 0x10); - std::uint8_t pad0[0x38]; // 000 char fileName[260]; // 038 std::uint8_t pad13C[0x7B]; // 13C @@ -52,8 +37,8 @@ namespace RE std::uint8_t pad1BC[0x4]; // 1BC std::uint8_t pad1C0[0x58]; // 1C0 TypedIndex fileIndex; // 218 - TextData fileAuthor; // 21C - TextData fileDescription; // 22C + BSString fileAuthor; // 21C + BSString fileDescription; // 22C }; static_assert(offsetof(TESFile, fileFlags) == 0x1B8); static_assert(offsetof(TESFile, fileIndex) == 0x218); From 1e76eb757c084c91e1610437eaf2dbd1dd0635c1 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:39:49 -0400 Subject: [PATCH 7/8] Remove MappingRowString from ControlMap --- include/RE/IDs.h | 1 - src/RE/C/ControlMap.cpp | 38 +++++--------------------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/include/RE/IDs.h b/include/RE/IDs.h index 8cb07f6a..1f46fe79 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -817,7 +817,6 @@ namespace RE::ID { inline constexpr REL::ID FormatMappingRow{ 124100 }; inline constexpr REL::ID FormatMappingRowWithKeyCodes{ 124101 }; - inline constexpr REL::ID MappingRowStringDtor{ 35706 }; inline constexpr REL::ID Singleton{ 938003 }; inline constexpr REL::ID InputContextNameTable{ 360965 }; inline constexpr REL::ID LoadMappings{ 124116 }; diff --git a/src/RE/C/ControlMap.cpp b/src/RE/C/ControlMap.cpp index 62edd2b2..ae8cffc0 100644 --- a/src/RE/C/ControlMap.cpp +++ b/src/RE/C/ControlMap.cpp @@ -1,46 +1,18 @@ #include "RE/C/ControlMap.h" -#include +#include "RE/B/BSStringT.h" namespace RE { - namespace - { - struct alignas(8) MappingRowString - { - ~MappingRowString() - { - static REL::Relocation destroy{ ID::ControlMap::MappingRowStringDtor }; - destroy(this); - } - - [[nodiscard]] const char* c_str() const - { - if (capacity <= sizeof(storage)) { - return storage; - } - const char* data; - std::memcpy(&data, storage, sizeof(data)); - return data; - } - - char storage[0xC]{}; - std::uint16_t capacity{ 1 }; - std::uint16_t unk0E{}; - }; - static_assert(sizeof(MappingRowString) == 0x10); - static_assert(offsetof(MappingRowString, capacity) == 0xC); - } - std::string ControlMap::FormatMappingRow( const char* a_event, const char* a_keyboard, const char* a_mouse, const char* a_gamepad, bool a_keyboardVisible, bool a_mouseVisible, bool a_gamepadVisible, std::uint32_t a_controlMask, std::uint32_t a_groupMask, bool a_required) { - using func_t = MappingRowString* (*)(MappingRowString*, const char*, const char*, const char*, const char*, + using func_t = BSString* (*)(BSString*, const char*, const char*, const char*, const char*, bool, bool, bool, std::uint32_t, std::uint32_t, bool); static REL::Relocation func{ ID::ControlMap::FormatMappingRow }; - MappingRowString text; + BSString text; func(&text, a_event, a_keyboard, a_mouse, a_gamepad, a_keyboardVisible, a_mouseVisible, a_gamepadVisible, a_controlMask, a_groupMask, a_required); return text.c_str(); @@ -51,10 +23,10 @@ namespace RE bool a_keyboardVisible, bool a_mouseVisible, bool a_gamepadVisible, std::uint32_t a_controlMask, std::uint32_t a_groupMask, bool a_required) { - using func_t = MappingRowString* (*)(MappingRowString*, const char*, std::uint32_t, std::uint32_t, std::uint32_t, + using func_t = BSString* (*)(BSString*, const char*, std::uint32_t, std::uint32_t, std::uint32_t, bool, bool, bool, std::uint32_t, std::uint32_t, bool); static REL::Relocation func{ ID::ControlMap::FormatMappingRowWithKeyCodes }; - MappingRowString text; + BSString text; func(&text, a_event, a_keyboard, a_mouse, a_gamepad, a_keyboardVisible, a_mouseVisible, a_gamepadVisible, a_controlMask, a_groupMask, a_required); return text.c_str(); From aab3de00dd91a80358518604a97699f78984cd9c Mon Sep 17 00:00:00 2001 From: DJLegends Date: Mon, 28 Sep 2026 23:47:29 -0500 Subject: [PATCH 8/8] Use shared BSString in DebugMessageBox and allow a custom heading --- include/RE/IDs.h | 6 ------ include/RE/M/Misc.h | 7 ++++--- src/RE/M/Misc.cpp | 39 ++++++++++----------------------------- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/include/RE/IDs.h b/include/RE/IDs.h index 2245151e..6cd87d5c 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -451,11 +451,6 @@ namespace RE::ID inline constexpr REL::ID GetEntryW{ 1186743 }; } - namespace BSStringT - { - inline constexpr REL::ID Assign{ 36345 }; - } - namespace BSStorage { inline constexpr REL::ID FlushStreamBuffer{ 0 }; // 72433 @@ -1355,7 +1350,6 @@ namespace RE::ID { inline constexpr REL::ID Singleton{ 938019 }; inline constexpr REL::ID CreateMessageBox{ 114231 }; - inline constexpr REL::ID DestroyMessageBoxParams{ 43998 }; } namespace Misc diff --git a/include/RE/M/Misc.h b/include/RE/M/Misc.h index b20e72d8..715e48ba 100644 --- a/include/RE/M/Misc.h +++ b/include/RE/M/Misc.h @@ -9,9 +9,10 @@ namespace RE { - // Queues a DEBUG message with the game's localized OK button. Call on the - // game thread after data loads. Does nothing if the message manager is absent. - void DebugMessageBox(const char* a_message); + // Queues a message with the game's localized OK button. Call on the game + // thread after data loads. A null message is empty; a null header uses DEBUG. + // Does nothing if the message manager is absent. + void DebugMessageBox(const char* a_message, const char* a_header = "DEBUG"); inline void DebugNotification(const char* a_notification, const char* a_soundToPlay = nullptr, bool a_cancelIfAlreadyQueued = true, bool a_arg4 = false) { diff --git a/src/RE/M/Misc.cpp b/src/RE/M/Misc.cpp index 72e4f898..552ea619 100644 --- a/src/RE/M/Misc.cpp +++ b/src/RE/M/Misc.cpp @@ -1,5 +1,7 @@ #include "RE/M/Misc.h" +#include "RE/B/BSStringT.h" + namespace RE { class IMessageBoxCallback; @@ -7,36 +9,16 @@ namespace RE namespace { - // The native request uses BSStringT. - // Its inline storage differs from the BSStringT currently exposed by this - // library. Keep the ABI private and let the engine assign and destroy it. - struct MessageBoxString - { - std::byte storage[0xC]{}; // 00: inline characters or owned pointer - std::uint16_t capacity{ 1 }; // 0C: includes the terminator - std::uint16_t length{}; // 0E - }; - static_assert(sizeof(MessageBoxString) == 0x10); - static_assert(offsetof(MessageBoxString, capacity) == 0xC); - static_assert(offsetof(MessageBoxString, length) == 0xE); - struct MessageBoxParams { MessageBoxParams() = default; MessageBoxParams(const MessageBoxParams&) = delete; MessageBoxParams& operator=(const MessageBoxParams&) = delete; - ~MessageBoxParams() - { - using func_t = void (*)(MessageBoxParams*); - static REL::Relocation func{ ID::MessageMenuManager::DestroyMessageBoxParams }; - func(this); - } - IMessageBoxCallback* callback{}; // 00 - MessageBoxString header; // 08 - MessageBoxString body; // 18 - MessageBoxString unk28; // 28 + BSString header; // 08 + BSString body; // 18 + BSString unk28; // 28 std::uint32_t warningContext{ 5 }; // 38: script }; static_assert(sizeof(MessageBoxParams) == 0x40); @@ -47,7 +29,7 @@ namespace RE static_assert(offsetof(MessageBoxParams, warningContext) == 0x38); } - void DebugMessageBox(const char* a_message) + void DebugMessageBox(const char* a_message, const char* a_header) { static REL::Relocation singleton{ ID::MessageMenuManager::Singleton }; auto manager = *singleton; @@ -56,13 +38,12 @@ namespace RE } MessageBoxParams params; - using assign_t = bool (*)(MessageBoxString*, const char*, std::size_t); - static REL::Relocation assign{ ID::BSStringT::Assign }; - assign(¶ms.header, "DEBUG", 0); - assign(¶ms.body, a_message, 0); + params.header.Assign(a_header ? a_header : "DEBUG"); + params.body.Assign(a_message); // This overload supplies the localized OK button and moves the owned - // strings/callback into its request. The destructor handles either state. + // strings into its request. BSString cleans up any buffers not moved out; + // this helper never supplies a callback. using create_t = void (*)(MessageMenuManager*, MessageBoxParams*, bool); static REL::Relocation create{ ID::MessageMenuManager::CreateMessageBox }; create(manager, ¶ms, false);