From c82c19e06faaf0f657b855a242c7a6d7d27a8552 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:04:59 -0400 Subject: [PATCH 1/9] WIP BGSConstructible shape --- include/RE/B/BGSConstructibleObject.h | 22 ++-- include/RE/B/BGSCraftableForm.h | 16 ++- include/RE/B/BSTHeapSTLVector.h | 167 ++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 include/RE/B/BSTHeapSTLVector.h diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index 5ae31b6..fed4247 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -3,11 +3,13 @@ #include "RE/B/BGSCraftableForm.h" #include "RE/B/BGSCraftingUseSound.h" #include "RE/B/BGSPickupPutdownSounds.h" +#include "RE/B/BSTHeapSTLVector.h" #include "RE/B/BSTList.h" #include "RE/T/TESValueForm.h" namespace RE { + class BGSKeyword; class TESBoundObject; class TESGlobal; @@ -33,15 +35,17 @@ namespace RE ~BGSConstructibleObject() override; // 00 // members - std::byte category[0x18]; // 150 - std::vector - TESBoundObject* unk168; // 168 - BGSCurveForm* unk170; // 170 - TESGlobal* buildLimit; // 178 - BSTArray>* unk180; // 180 - REX::TEnum learnMethod; // 188 - TESGlobal* unk190; // 190 - BGSKeyword* unk198; // 198 - std::uint32_t unk1A0; // 1A0 + // category models the engine's `std::vector>`. + // Modeled as BSTHeapSTLVector (read-only; writing would corrupt the game heap). + BSTHeapSTLVector category; // 150 - CK RecipeFilters + TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) + BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable + TESGlobal* maxBuildCount; // 178 - CK MaxBuildCount + BSTArray>* workbenchRepairRecipe; // 180 - CK WorkbenchRepairRecipe + REX::TEnum learnMethod; // 188 - CK LearnMethod enum + TESGlobal* unk190; // 190 - TESGlobal??? + BGSKeyword* unk198; // 198 - BGSKeyword??? + std::uint32_t flags; // 1A0 - CK flag bitfield incl. ExcludeFromWorkshopLOD (bit layout TBD) (???) }; static_assert(sizeof(BGSConstructibleObject) == 0x1A8); static_assert(offsetof(BGSConstructibleObject, pickupSound) == 0xA8); // BGSPickupPutdownSounds base @ +0x0A0 diff --git a/include/RE/B/BGSCraftableForm.h b/include/RE/B/BGSCraftableForm.h index 1e7fa1d..c25033d 100644 --- a/include/RE/B/BGSCraftableForm.h +++ b/include/RE/B/BGSCraftableForm.h @@ -27,14 +27,18 @@ namespace RE virtual void Unk_62(); // 62 // members - BGSKeyword* benchKeyword; // 60 + BGSKeyword* benchKeyword; // 60 - CK WorkbenchKeyword TESCondition conditions; // 68 - BSTArray>* components; // 78 + BSTArray>* components; // 78 - CK RequiredItemList BSTArray>* requiredPerks; // 80 - TESForm* createdObject; // 88 - std::uint8_t unk90; // 90 - float menuSortOrder; // 94 - std::uint8_t unk98; // 98 + // createdObject is the CK "CreatedObject" field. Runtime evidence (2931 base-game + // COBJs) shows 16 distinct formTypes here, including kOMOD (1147), kFLST (478), + // kMSTT (568), kMISC (202), kCONT (174), kIDLE (86), kFURN (81), kSTAT (63), etc. + // Do NOT assume this is a TESBoundObject; the field is generic TESForm*. + TESForm* createdObject; // 88 - CK CreatedObject (arbitrary TESForm, not just TESBoundObject) + std::uint8_t unk90; // 90 - heatmap: 2911/3091 populated, values ~1 (CreatedObjectCount candidate) + float menuSortOrder; // 94 - CK MenuPriorityOrder + std::uint8_t unk98; // 98 - heatmap: 12/3091 populated (rare flag byte) }; static_assert(sizeof(BGSCraftableForm) == 0xA0); static_assert(offsetof(BGSCraftableForm, unk18) == 0x18); // TESForm base @ +0x00 diff --git a/include/RE/B/BSTHeapSTLVector.h b/include/RE/B/BSTHeapSTLVector.h new file mode 100644 index 0000000..efda912 --- /dev/null +++ b/include/RE/B/BSTHeapSTLVector.h @@ -0,0 +1,167 @@ +#pragma once + +#include "RE/M/MemoryManager.h" + +#include +#include +#include + +namespace RE +{ + // View + owner of Starfield engine members commented as + // `std::vector>`. Layout is three pointers + // (begin / end / capacity-end), so sizeof == 0x18 on x64. + // + // Why not use `std::vector` directly? MSVC's debug build + // (`_ITERATOR_DEBUG_LEVEL=2`) inflates `sizeof(std::vector)` beyond 0x18 + // because of checked-iterator bookkeeping. This wrapper is build-mode-stable. + // + // Mutation uses the engine's MemoryManager (RE::malloc / RE::free), which is the + // same global heap Bethesda's BSTHeapSTLAllocator ultimately delegates to. Buffers + // allocated by the game can be freed by us and vice-versa without cross-heap + // corruption. The raw BSTHeapSTLAllocatorBase REL::IDs (34039/34440) are NOT + // plain allocators — calling 34039 with (bytes, align) returns an image-space + // pointer, not a heap buffer, so we avoid them. + // + // Threading: no internal locking. Callers must ensure no other thread is + // iterating or mutating the same vector. Safe during OnDataLoaded / main-thread + // script ticks; not safe from background threads. + template + class BSTHeapSTLVector + { + public: + using value_type = T; + using pointer = T*; + using const_pointer = const T*; + using reference = T&; + using const_reference = const T&; + using iterator = T*; + using const_iterator = const T*; + using size_type = std::size_t; + using difference_type = std::ptrdiff_t; + + [[nodiscard]] iterator begin() noexcept { return _begin; } + [[nodiscard]] const_iterator begin() const noexcept { return _begin; } + [[nodiscard]] const_iterator cbegin() const noexcept { return _begin; } + + [[nodiscard]] iterator end() noexcept { return _end; } + [[nodiscard]] const_iterator end() const noexcept { return _end; } + [[nodiscard]] const_iterator cend() const noexcept { return _end; } + + [[nodiscard]] pointer data() noexcept { return _begin; } + [[nodiscard]] const_pointer data() const noexcept { return _begin; } + + [[nodiscard]] size_type size() const noexcept + { + return static_cast(_end - _begin); + } + + [[nodiscard]] size_type capacity() const noexcept + { + return static_cast(_capacityEnd - _begin); + } + + [[nodiscard]] bool empty() const noexcept { return _begin == _end; } + + [[nodiscard]] reference operator[](size_type a_index) noexcept { return _begin[a_index]; } + [[nodiscard]] const_reference operator[](size_type a_index) const noexcept { return _begin[a_index]; } + + [[nodiscard]] reference front() noexcept { return *_begin; } + [[nodiscard]] const_reference front() const noexcept { return *_begin; } + [[nodiscard]] reference back() noexcept { return _end[-1]; } + [[nodiscard]] const_reference back() const noexcept { return _end[-1]; } + + // Mutation ----------------------------------------------------------------- + + // Appends a copy of a_value. Grows the buffer (doubling, minimum 4) via the + // game's allocator when capacity is exhausted. Preserves existing elements. + void push_back(T a_value) + { + if (_end == _capacityEnd) { + Grow(size() + 1); + } + *_end = a_value; + ++_end; + } + + // Removes the element at index a_index (0-based). Shifts subsequent + // elements down and decrements end. Does nothing if a_index >= size(). + void erase(size_type a_index) + { + if (a_index >= size()) { + return; + } + for (auto p = _begin + a_index; p + 1 != _end; ++p) { + p[0] = p[1]; + } + --_end; + } + + // Removes the first element equal to a_value and returns whether any slot + // was removed. Does not touch the backing buffer's capacity. + [[nodiscard]] bool erase_value(const T& a_value) + { + for (size_type i = 0; i < size(); ++i) { + if (_begin[i] == a_value) { + erase(i); + return true; + } + } + return false; + } + + // Empties the vector without freeing the buffer (capacity unchanged). + void clear() noexcept { _end = _begin; } + + // Pre-grows capacity to at least a_min. No-op if capacity already suffices. + void reserve(size_type a_min) + { + if (a_min > capacity()) { + Grow(a_min); + } + } + + // Frees the backing buffer via the game's allocator and zeroes all three + // pointers. Use this (not `std::free`) when discarding a vector we built. + void release_buffer() + { + if (_begin) { + RE::free(_begin, alignof(T) > alignof(std::max_align_t)); + } + _begin = nullptr; + _end = nullptr; + _capacityEnd = nullptr; + } + + private: + // Ensures capacity >= a_min, copying existing content. Sets _end correctly. + void Grow(size_type a_min) + { + const auto oldSize = size(); + const auto oldCapacity = capacity(); + auto newCapacity = std::max(4, oldCapacity * 2); + while (newCapacity < a_min) { + newCapacity *= 2; + } + + const auto bytes = newCapacity * sizeof(T); + const auto alignRequired = alignof(T) > alignof(std::max_align_t); + auto* newBuffer = static_cast(RE::malloc(bytes, alignRequired ? alignof(T) : 0)); + if (oldSize > 0) { + std::memcpy(newBuffer, _begin, oldSize * sizeof(T)); + } + if (_begin) { + RE::free(_begin, alignRequired); + } + _begin = newBuffer; + _end = newBuffer + oldSize; + _capacityEnd = newBuffer + newCapacity; + } + + // members + T* _begin{ nullptr }; // 00 + T* _end{ nullptr }; // 08 + T* _capacityEnd{ nullptr }; // 10 + }; + static_assert(sizeof(BSTHeapSTLVector) == 0x18); +} From 4d86be8b5a4481b95949bfa97c70ff4315f6c249 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:20:01 -0400 Subject: [PATCH 2/9] WIP ADd keyword manip --- include/RE/B/BGSConstructibleObject.h | 38 ++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index fed4247..c43a068 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -34,9 +34,45 @@ namespace RE ~BGSConstructibleObject() override; // 00 + // Runtime bulk-patching of the CK RecipeFilters keyword list (the + // `category` member). Safe on any COBJ regardless of which mod authored + // it; mutates via MemoryManager so the engine can free the buffer on + // shutdown. Call from the main thread at a stable time (e.g. OnDataLoaded + // or a script callback); not thread-safe. + [[nodiscard]] bool HasCategoryKeyword(const BGSKeyword* a_keyword) const noexcept + { + if (!a_keyword) { + return false; + } + for (const auto* kw : category) { + if (kw == a_keyword) { + return true; + } + } + return false; + } + + bool AddCategoryKeyword(BGSKeyword* a_keyword) + { + if (!a_keyword || HasCategoryKeyword(a_keyword)) { + return false; + } + category.push_back(a_keyword); + return true; + } + + bool RemoveCategoryKeyword(const BGSKeyword* a_keyword) + { + if (!a_keyword) { + return false; + } + return category.erase_value(const_cast(a_keyword)); + } + // members // category models the engine's `std::vector>`. - // Modeled as BSTHeapSTLVector (read-only; writing would corrupt the game heap). + // Modeled as BSTHeapSTLVector so we can safely read and mutate via the + // game's MemoryManager. CK field name: RecipeFilters. BSTHeapSTLVector category; // 150 - CK RecipeFilters TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable From 2e36c79b7caad06e08d69b58f97fe87810dbefcf Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:23:30 -0400 Subject: [PATCH 3/9] Maybe BGSTypedKeywordValueArray? --- include/RE/B/BGSConstructibleObject.h | 48 ++++-------------------- include/RE/B/BGSTypedKeywordValueArray.h | 29 +++++++++++++- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index c43a068..88f8c38 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -3,13 +3,12 @@ #include "RE/B/BGSCraftableForm.h" #include "RE/B/BGSCraftingUseSound.h" #include "RE/B/BGSPickupPutdownSounds.h" -#include "RE/B/BSTHeapSTLVector.h" +#include "RE/B/BGSTypedKeywordValueArray.h" #include "RE/B/BSTList.h" #include "RE/T/TESValueForm.h" namespace RE { - class BGSKeyword; class TESBoundObject; class TESGlobal; @@ -34,46 +33,13 @@ namespace RE ~BGSConstructibleObject() override; // 00 - // Runtime bulk-patching of the CK RecipeFilters keyword list (the - // `category` member). Safe on any COBJ regardless of which mod authored - // it; mutates via MemoryManager so the engine can free the buffer on - // shutdown. Call from the main thread at a stable time (e.g. OnDataLoaded - // or a script callback); not thread-safe. - [[nodiscard]] bool HasCategoryKeyword(const BGSKeyword* a_keyword) const noexcept - { - if (!a_keyword) { - return false; - } - for (const auto* kw : category) { - if (kw == a_keyword) { - return true; - } - } - return false; - } - - bool AddCategoryKeyword(BGSKeyword* a_keyword) - { - if (!a_keyword || HasCategoryKeyword(a_keyword)) { - return false; - } - category.push_back(a_keyword); - return true; - } - - bool RemoveCategoryKeyword(const BGSKeyword* a_keyword) - { - if (!a_keyword) { - return false; - } - return category.erase_value(const_cast(a_keyword)); - } - // members - // category models the engine's `std::vector>`. - // Modeled as BSTHeapSTLVector so we can safely read and mutate via the - // game's MemoryManager. CK field name: RecipeFilters. - BSTHeapSTLVector category; // 150 - CK RecipeFilters + // category models the engine's typed keyword array for the RecipeFilter + // keyword type; same 0x18-byte begin/end/capacityEnd layout as + // BGSAttachParentArray but tagged for a different kind of keyword. + // Use category.HasKeyword() / AddKeyword() / RemoveKeyword() to query or + // mutate it at runtime. CK field name: RecipeFilters. + BGSTypedKeywordValueArray category; // 150 - CK RecipeFilters TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable TESGlobal* maxBuildCount; // 178 - CK MaxBuildCount diff --git a/include/RE/B/BGSTypedKeywordValueArray.h b/include/RE/B/BGSTypedKeywordValueArray.h index 7af8234..251a96e 100644 --- a/include/RE/B/BGSTypedKeywordValueArray.h +++ b/include/RE/B/BGSTypedKeywordValueArray.h @@ -73,11 +73,33 @@ namespace RE } } + // Fast path: existing buffer has spare capacity. Avoids heap churn + // when the field was grown previously. + if (end < capacityEnd) { + *end = a_keyword; + ++end; + return true; + } + + // Slow path: allocate a new buffer (with headroom), copy the old + // contents, release the old buffer so we don't leak on repeated + // mutation. Freeing via MemoryManager is proven compatible with the + // engine's BSTHeapSTLAllocator (same global heap). const auto oldSize = static_cast(end - begin); const auto newSize = oldSize + 1; + // Double-and-min-4 growth matches our BSTHeapSTLVector policy and + // keeps amortized cost down for bulk-patch scenarios. + const auto oldCapacity = static_cast(capacityEnd - begin); + auto newCapacity = oldCapacity * 2; + if (newCapacity < 4) { + newCapacity = 4; + } + if (newCapacity < newSize) { + newCapacity = newSize; + } auto* newData = static_cast( - RE::MemoryManager::GetSingleton()->Allocate(sizeof(BGSKeyword*) * newSize, 0, false)); + RE::MemoryManager::GetSingleton()->Allocate(sizeof(BGSKeyword*) * newCapacity, 0, false)); if (!newData) { return false; @@ -88,9 +110,12 @@ namespace RE } newData[oldSize] = a_keyword; + if (begin) { + RE::MemoryManager::GetSingleton()->Free(begin, false); + } begin = newData; end = newData + newSize; - capacityEnd = newData + newSize; + capacityEnd = newData + newCapacity; return true; } From b85d0e394a1e25ac722c2006720cef2325f29db1 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:35:49 -0400 Subject: [PATCH 4/9] BGSTypedKeywordValueArray seems to be a BSTHeapSTLVector rename category -> recipeFilters --- include/RE/B/BGSConstructibleObject.h | 4 +- include/RE/B/BGSTypedKeywordValueArray.h | 84 +++++------------------- include/RE/B/BSTHeapSTLVector.h | 8 +-- 3 files changed, 24 insertions(+), 72 deletions(-) diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index 88f8c38..323bb01 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -39,7 +39,7 @@ namespace RE // BGSAttachParentArray but tagged for a different kind of keyword. // Use category.HasKeyword() / AddKeyword() / RemoveKeyword() to query or // mutate it at runtime. CK field name: RecipeFilters. - BGSTypedKeywordValueArray category; // 150 - CK RecipeFilters + BGSTypedKeywordValueArray recipeFilters; // 150 - CK RecipeFilters (prev. "category") TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable TESGlobal* maxBuildCount; // 178 - CK MaxBuildCount @@ -53,5 +53,5 @@ namespace RE static_assert(offsetof(BGSConstructibleObject, pickupSound) == 0xA8); // BGSPickupPutdownSounds base @ +0x0A0 static_assert(offsetof(BGSConstructibleObject, value) == 0x110); // TESValueForm base @ +0x108 static_assert(offsetof(BGSConstructibleObject, craftingUseSound) == 0x120); // BGSCraftingUseSound base @ +0x118 - static_assert(offsetof(BGSConstructibleObject, category) == 0x150); // first derived member + static_assert(offsetof(BGSConstructibleObject, recipeFilters) == 0x150); // first derived member } diff --git a/include/RE/B/BGSTypedKeywordValueArray.h b/include/RE/B/BGSTypedKeywordValueArray.h index 251a96e..750a96f 100644 --- a/include/RE/B/BGSTypedKeywordValueArray.h +++ b/include/RE/B/BGSTypedKeywordValueArray.h @@ -1,7 +1,7 @@ #pragma once #include "RE/B/BGSKeyword.h" -#include "RE/M/MemoryManager.h" +#include "RE/B/BSTHeapSTLVector.h" namespace RE { @@ -40,11 +40,15 @@ namespace RE }; static_assert(sizeof(BGSTypedKeywordValue) == 0x8); - // Best-fit model: BGSTypedKeywordValueArray may be - // BSComponentDB2-backed index records during preload/finalize, while loaded - // ARMO/WEAP attachParents runtime data is a contiguous BGSKeyword* buffer. + // By the time plugins touch these at runtime (post-data-loaded ARMO and WEAP + // attachParents, form keyword lists, etc.) the backing storage is a contiguous + // BGSKeyword* buffer managed by the game's BSTHeapSTLAllocator — the same + // begin / end / capacityEnd triplet modeled by BSTHeapSTLVector. During + // preload/finalize of some keyword types (notably kAttachPoint) the field may + // briefly hold BSComponentDB2 index records instead of BGSKeyword pointers; + // do not mutate through this view during that window. template - class BGSTypedKeywordValueArray + class BGSTypedKeywordValueArray : public BSTHeapSTLVector { public: [[nodiscard]] bool HasKeyword(BGSKeyword* a_keyword) @@ -52,9 +56,8 @@ namespace RE if (!a_keyword) { return false; } - - for (auto it = begin; it != end; ++it) { - if (*it == a_keyword) { + for (auto* kw : *this) { + if (kw == a_keyword) { return true; } } @@ -66,56 +69,12 @@ namespace RE if (!a_keyword) { return false; } - - for (auto it = begin; it != end; ++it) { - if (*it && (*it)->formID == a_keyword->formID) { + for (auto* kw : *this) { + if (kw && kw->formID == a_keyword->formID) { return false; } } - - // Fast path: existing buffer has spare capacity. Avoids heap churn - // when the field was grown previously. - if (end < capacityEnd) { - *end = a_keyword; - ++end; - return true; - } - - // Slow path: allocate a new buffer (with headroom), copy the old - // contents, release the old buffer so we don't leak on repeated - // mutation. Freeing via MemoryManager is proven compatible with the - // engine's BSTHeapSTLAllocator (same global heap). - const auto oldSize = static_cast(end - begin); - const auto newSize = oldSize + 1; - // Double-and-min-4 growth matches our BSTHeapSTLVector policy and - // keeps amortized cost down for bulk-patch scenarios. - const auto oldCapacity = static_cast(capacityEnd - begin); - auto newCapacity = oldCapacity * 2; - if (newCapacity < 4) { - newCapacity = 4; - } - if (newCapacity < newSize) { - newCapacity = newSize; - } - - auto* newData = static_cast( - RE::MemoryManager::GetSingleton()->Allocate(sizeof(BGSKeyword*) * newCapacity, 0, false)); - - if (!newData) { - return false; - } - - for (std::size_t i = 0; i < oldSize; ++i) { - newData[i] = begin[i]; - } - - newData[oldSize] = a_keyword; - if (begin) { - RE::MemoryManager::GetSingleton()->Free(begin, false); - } - begin = newData; - end = newData + newSize; - capacityEnd = newData + newCapacity; + push_back(a_keyword); return true; } @@ -124,22 +83,15 @@ namespace RE if (!a_keyword) { return false; } - for (auto it = begin; it != end; ++it) { - if (*it && (*it)->formID == a_keyword->formID) { - for (auto jt = it; jt + 1 != end; ++jt) { - *jt = *(jt + 1); - } - --end; - *end = nullptr; + for (size_type i = 0; i < size(); ++i) { + auto* kw = (*this)[i]; + if (kw && kw->formID == a_keyword->formID) { + erase(i); return true; } } return false; } - - BGSKeyword** begin; // 00 - BGSKeyword** end; // 08 - BGSKeyword** capacityEnd; // 10 }; static_assert(sizeof(BGSTypedKeywordValueArray) == 0x18); } diff --git a/include/RE/B/BSTHeapSTLVector.h b/include/RE/B/BSTHeapSTLVector.h index efda912..acb77a7 100644 --- a/include/RE/B/BSTHeapSTLVector.h +++ b/include/RE/B/BSTHeapSTLVector.h @@ -20,8 +20,8 @@ namespace RE // same global heap Bethesda's BSTHeapSTLAllocator ultimately delegates to. Buffers // allocated by the game can be freed by us and vice-versa without cross-heap // corruption. The raw BSTHeapSTLAllocatorBase REL::IDs (34039/34440) are NOT - // plain allocators — calling 34039 with (bytes, align) returns an image-space - // pointer, not a heap buffer, so we avoid them. + // plain allocators - calling 34039 with (bytes, align) returns an image-space + // pointer, not a heap buffer. Probably just wrong offset ids and needs to be further RE'd // // Threading: no internal locking. Callers must ensure no other thread is // iterating or mutating the same vector. Safe during OnDataLoaded / main-thread @@ -159,8 +159,8 @@ namespace RE } // members - T* _begin{ nullptr }; // 00 - T* _end{ nullptr }; // 08 + T* _begin{ nullptr }; // 00 + T* _end{ nullptr }; // 08 T* _capacityEnd{ nullptr }; // 10 }; static_assert(sizeof(BSTHeapSTLVector) == 0x18); From 7430eebc9a0d10f39fd26ba36eeb0c9c997c5e57 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:06:24 -0400 Subject: [PATCH 5/9] Make BSTHeapSTLVector have similar vibe as BSTArray --- include/RE/B/BSTHeapSTLVector.h | 87 +++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/include/RE/B/BSTHeapSTLVector.h b/include/RE/B/BSTHeapSTLVector.h index acb77a7..93d2798 100644 --- a/include/RE/B/BSTHeapSTLVector.h +++ b/include/RE/B/BSTHeapSTLVector.h @@ -3,6 +3,7 @@ #include "RE/M/MemoryManager.h" #include +#include #include #include @@ -40,45 +41,45 @@ namespace RE using size_type = std::size_t; using difference_type = std::ptrdiff_t; - [[nodiscard]] iterator begin() noexcept { return _begin; } - [[nodiscard]] const_iterator begin() const noexcept { return _begin; } - [[nodiscard]] const_iterator cbegin() const noexcept { return _begin; } + [[nodiscard]] constexpr iterator begin() noexcept { return _begin; } + [[nodiscard]] constexpr const_iterator begin() const noexcept { return _begin; } + [[nodiscard]] constexpr const_iterator cbegin() const noexcept { return _begin; } - [[nodiscard]] iterator end() noexcept { return _end; } - [[nodiscard]] const_iterator end() const noexcept { return _end; } - [[nodiscard]] const_iterator cend() const noexcept { return _end; } + [[nodiscard]] constexpr iterator end() noexcept { return _end; } + [[nodiscard]] constexpr const_iterator end() const noexcept { return _end; } + [[nodiscard]] constexpr const_iterator cend() const noexcept { return _end; } - [[nodiscard]] pointer data() noexcept { return _begin; } - [[nodiscard]] const_pointer data() const noexcept { return _begin; } + [[nodiscard]] constexpr pointer data() noexcept { return _begin; } + [[nodiscard]] constexpr const_pointer data() const noexcept { return _begin; } - [[nodiscard]] size_type size() const noexcept + [[nodiscard]] constexpr size_type size() const noexcept { return static_cast(_end - _begin); } - [[nodiscard]] size_type capacity() const noexcept + [[nodiscard]] constexpr size_type capacity() const noexcept { return static_cast(_capacityEnd - _begin); } - [[nodiscard]] bool empty() const noexcept { return _begin == _end; } + [[nodiscard]] constexpr bool empty() const noexcept { return _begin == _end; } - [[nodiscard]] reference operator[](size_type a_index) noexcept { return _begin[a_index]; } - [[nodiscard]] const_reference operator[](size_type a_index) const noexcept { return _begin[a_index]; } + [[nodiscard]] constexpr reference operator[](size_type a_index) noexcept { return _begin[a_index]; } + [[nodiscard]] constexpr const_reference operator[](size_type a_index) const noexcept { return _begin[a_index]; } - [[nodiscard]] reference front() noexcept { return *_begin; } - [[nodiscard]] const_reference front() const noexcept { return *_begin; } - [[nodiscard]] reference back() noexcept { return _end[-1]; } - [[nodiscard]] const_reference back() const noexcept { return _end[-1]; } + [[nodiscard]] constexpr reference front() noexcept { return *_begin; } + [[nodiscard]] constexpr const_reference front() const noexcept { return *_begin; } + [[nodiscard]] constexpr reference back() noexcept { return _end[-1]; } + [[nodiscard]] constexpr const_reference back() const noexcept { return _end[-1]; } // Mutation ----------------------------------------------------------------- - // Appends a copy of a_value. Grows the buffer (doubling, minimum 4) via the - // game's allocator when capacity is exhausted. Preserves existing elements. + // Appends a copy of a_value. Grows the buffer via the same automatic + // reserve policy used by BSTArray when capacity is exhausted. void push_back(T a_value) { if (_end == _capacityEnd) { - Grow(size() + 1); + reserve_auto(size() + 1); } *_end = a_value; ++_end; @@ -117,7 +118,16 @@ namespace RE void reserve(size_type a_min) { if (a_min > capacity()) { - Grow(a_min); + reserve_exact(a_min); + } + } + + void shrink_to_fit() { reserve_exact(size()); } + + void pop_back() + { + if (!empty()) { + --_end; } } @@ -133,20 +143,26 @@ namespace RE _capacityEnd = nullptr; } - private: - // Ensures capacity >= a_min, copying existing content. Sets _end correctly. - void Grow(size_type a_min) + protected: + void reserve_exact(size_type a_capacity) { - const auto oldSize = size(); - const auto oldCapacity = capacity(); - auto newCapacity = std::max(4, oldCapacity * 2); - while (newCapacity < a_min) { - newCapacity *= 2; + assert(a_capacity >= size()); + if (a_capacity == capacity()) { + return; + } + if (a_capacity == 0) { + release_buffer(); + return; } - const auto bytes = newCapacity * sizeof(T); + const auto oldSize = size(); + const auto bytes = a_capacity * sizeof(T); const auto alignRequired = alignof(T) > alignof(std::max_align_t); auto* newBuffer = static_cast(RE::malloc(bytes, alignRequired ? alignof(T) : 0)); + if (!newBuffer) { + REX::FAIL("out of memory"); + } + std::memset(newBuffer, 0, bytes); if (oldSize > 0) { std::memcpy(newBuffer, _begin, oldSize * sizeof(T)); } @@ -155,7 +171,16 @@ namespace RE } _begin = newBuffer; _end = newBuffer + oldSize; - _capacityEnd = newBuffer + newCapacity; + _capacityEnd = newBuffer + a_capacity; + } + + private: + void reserve_auto(size_type a_capacity) + { + if (a_capacity > capacity()) { + const auto grow = std::max(a_capacity, capacity() * 2); + reserve_exact(grow); + } } // members From e0b543f0224f2c1883b103d606df1daa5b766f7f Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:06:52 -0400 Subject: [PATCH 6/9] BGSTypedKeywordValueArray AddKeyword keeps engine behavior Kinda jank but :shrug: --- include/RE/B/BGSConstructibleObject.h | 8 +++----- include/RE/B/BGSTypedKeywordValueArray.h | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index 323bb01..1c903f8 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -34,11 +34,9 @@ namespace RE ~BGSConstructibleObject() override; // 00 // members - // category models the engine's typed keyword array for the RecipeFilter - // keyword type; same 0x18-byte begin/end/capacityEnd layout as - // BGSAttachParentArray but tagged for a different kind of keyword. - // Use category.HasKeyword() / AddKeyword() / RemoveKeyword() to query or - // mutate it at runtime. CK field name: RecipeFilters. + // recipeFilters models the engine's typed keyword array + // Use recipeFilters.HasKeyword() / AddKeyword() / RemoveKeyword() to + // query or mutate it at runtime. BGSTypedKeywordValueArray recipeFilters; // 150 - CK RecipeFilters (prev. "category") TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable diff --git a/include/RE/B/BGSTypedKeywordValueArray.h b/include/RE/B/BGSTypedKeywordValueArray.h index 750a96f..5a8f8d1 100644 --- a/include/RE/B/BGSTypedKeywordValueArray.h +++ b/include/RE/B/BGSTypedKeywordValueArray.h @@ -64,6 +64,10 @@ namespace RE return false; } + // Typed-keyword arrays appear to keep capacity tightly matched to live + // size after engine-driven adds. Reserve exactly size() + 1 here before + // appending so the generic vector stays vector-like, while this wrapper preserves the + // tighter capacityEnd post-condition that callers may observe on these fields. [[nodiscard]] bool AddKeyword(BGSKeyword* a_keyword) { if (!a_keyword) { @@ -74,7 +78,8 @@ namespace RE return false; } } - push_back(a_keyword); + this->reserve_exact(this->size() + 1); + this->push_back(a_keyword); return true; } From 12b7c74097145b01f1b4789fb31152fff7cecee3 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:07:09 -0400 Subject: [PATCH 7/9] HasKeyword does formid equality check --- include/RE/B/BGSTypedKeywordValueArray.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/include/RE/B/BGSTypedKeywordValueArray.h b/include/RE/B/BGSTypedKeywordValueArray.h index 5a8f8d1..f5dc05f 100644 --- a/include/RE/B/BGSTypedKeywordValueArray.h +++ b/include/RE/B/BGSTypedKeywordValueArray.h @@ -51,13 +51,17 @@ namespace RE class BGSTypedKeywordValueArray : public BSTHeapSTLVector { public: + // Membership test uses formID equality (not pointer equality) so that + // callers holding a BGSKeyword* from any lookup path — including forms + // rebuilt across load cycles — see the same answer as AddKeyword and + // RemoveKeyword, which also compare by formID. [[nodiscard]] bool HasKeyword(BGSKeyword* a_keyword) { if (!a_keyword) { return false; } for (auto* kw : *this) { - if (kw == a_keyword) { + if (kw && kw->formID == a_keyword->formID) { return true; } } From 555b5195129e975ae262be6477d318053d5e99f6 Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:13:40 -0400 Subject: [PATCH 8/9] erase clears tail slot --- include/RE/B/BSTHeapSTLVector.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/RE/B/BSTHeapSTLVector.h b/include/RE/B/BSTHeapSTLVector.h index 93d2798..acd6815 100644 --- a/include/RE/B/BSTHeapSTLVector.h +++ b/include/RE/B/BSTHeapSTLVector.h @@ -96,6 +96,7 @@ namespace RE p[0] = p[1]; } --_end; + *_end = value_type{}; } // Removes the first element equal to a_value and returns whether any slot From 83768a2b6e9d46742bd3cf4f2e88abc93aca471d Mon Sep 17 00:00:00 2001 From: ozooma10 <98544147+ozooma10@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:48:25 -0400 Subject: [PATCH 9/9] Apply suggestions from code review Co-authored-by: qudix <17361645+qudix@users.noreply.github.com> --- include/RE/B/BGSConstructibleObject.h | 3 --- include/RE/B/BGSCraftableForm.h | 4 ---- 2 files changed, 7 deletions(-) diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index 1c903f8..edc9032 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -34,9 +34,6 @@ namespace RE ~BGSConstructibleObject() override; // 00 // members - // recipeFilters models the engine's typed keyword array - // Use recipeFilters.HasKeyword() / AddKeyword() / RemoveKeyword() to - // query or mutate it at runtime. BGSTypedKeywordValueArray recipeFilters; // 150 - CK RecipeFilters (prev. "category") TESBoundObject* learnedFrom; // 168 - CK LearnedFrom (any TESBoundObject) BGSCurveForm* baseReturnScaleTable; // 170 - CK BaseReturnScaleTable diff --git a/include/RE/B/BGSCraftableForm.h b/include/RE/B/BGSCraftableForm.h index c25033d..cf14530 100644 --- a/include/RE/B/BGSCraftableForm.h +++ b/include/RE/B/BGSCraftableForm.h @@ -31,10 +31,6 @@ namespace RE TESCondition conditions; // 68 BSTArray>* components; // 78 - CK RequiredItemList BSTArray>* requiredPerks; // 80 - // createdObject is the CK "CreatedObject" field. Runtime evidence (2931 base-game - // COBJs) shows 16 distinct formTypes here, including kOMOD (1147), kFLST (478), - // kMSTT (568), kMISC (202), kCONT (174), kIDLE (86), kFURN (81), kSTAT (63), etc. - // Do NOT assume this is a TESBoundObject; the field is generic TESForm*. TESForm* createdObject; // 88 - CK CreatedObject (arbitrary TESForm, not just TESBoundObject) std::uint8_t unk90; // 90 - heatmap: 2911/3091 populated, values ~1 (CreatedObjectCount candidate) float menuSortOrder; // 94 - CK MenuPriorityOrder