// Shared chest: a second tab in the chest window whose contents belong to no // hero but to all of them (request 2026-10-17, docs/shared-chest.md). // // The chest window (cUI_Chest) works on the inventory at window +0x158 // (inventory 0x11, the hero's chest, set by its constructor 0x6ac7f0; a // 20 x 10 grid from (window x, 20)). The shared tab is an inventory of our // own. A cUI_TabSheet of the game's (the inventory's header look), its headers // in a column left of the grid, switches +0x158 between the two. Shift+click // (cInventoryManager::MoveItem 0x5dec80 from 0x11) follows the shown tab. // // Items: the hero file stores items as their type plus the object's own // serializer (vf1c(stream, 1, 0)); loadHero recreates them with // cObjectManager::Create(type, 0, 0, 0), vf1c(stream, 0, file version) and // gives them their new id. The shared chest does the same into // shared_chest.json in the save folder (mods/userfolders.cpp): written whenever a hero is saved, read at the start // of each game session; cleared when the session ends (the ids die with the // object manager). #include #include #include #include #include #include #include #include #include "game/engine/events.h" #include "game/engine/items.h" #include "game/engine/objects.h" #include "game/engine/orders.h" #include "game/engine/stream.h" #include "game/ui/tabs.h" #include "game/ui/text.h" #include "game/ui/texts.h" #include "mods/itemstack.h" #include "mods/sharedchest.h" #include "mods/skillslots.h" #include "mods/userfolders.h" #include "re/hook.h" #include "re/log.h" namespace mods::sharedchest { namespace { using nlohmann::json; constexpr int kFileVersion = 1; // cUI_Chest constexpr size_t kWindowInventory = 0x158; constexpr uint32_t kControlVisible = 1; // +0x10 bit 0 constexpr size_t kControlFlags = 0x10; // cInventoryEntry: the grid origin on screen and the item's size in its first cell. constexpr size_t kGridOriginX = 0x1164; constexpr size_t kGridOriginY = 0x1166; constexpr size_t kCellWidth = 5; // u8 in the cell, as PlaceItem stores it constexpr size_t kCellHeight = 6; // u8 constexpr int kCellPixels = 32; alignas(8) uint8_t g_storage[skillslots::kInventorySize]; bool g_constructed = false; // The shared chest of this session came from the file (only then is it saved). bool g_loaded = false; bool g_loadPending = false; void* g_chestWindow = nullptr; void* g_sheet = nullptr; // our cUI_TabSheet in it uint8_t* GameInventory(uint32_t index) { return skillslots::Inventory(index); } uint8_t* Shared() { if (!g_constructed) { using ConstructFn = void(__fastcall*)(void*, void*, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t); reinterpret_cast(0x005d7a10)(g_storage, nullptr, 0, 0, 0x14, 0xa, 0); // cInventoryEntry::Construct *reinterpret_cast(g_storage + game::kInventoryOwner) = game::kChestInventory; g_constructed = true; } return g_storage; } void ClearGrid() { std::memset(Shared() + game::kInventoryGrid, 0, game::GridCells(Shared()) * game::kGridCellSize); } bool SharedShown() { return g_chestWindow && *reinterpret_cast(static_cast(g_chestWindow) + kWindowInventory) == Shared(); } bool Visible(void* window) { return (*reinterpret_cast(static_cast(window) + kControlFlags) & kControlVisible) != 0; } // The chest window is open on the shared tab. bool SharedOpen() { return SharedShown() && Visible(g_chestWindow); } std::string FilePath() { return mods::userfolders::SaveDir() + "shared_chest.json"; } // --- items <-> JSON ------------------------------------------------------------ json ItemJson(const uint8_t* inventory, unsigned cell, uint32_t id) { void* item = game::GetObjectById(id); if (!item) { return nullptr; } const std::vector data = game::SerializeItem(item); const uint8_t* entry = inventory + game::kInventoryGrid + cell * game::kGridCellSize; json j = {{"cell", cell}, {"w", entry[kCellWidth]}, {"h", entry[kCellHeight]}, {"type", game::ItemType(item)}, {"data", game::Hex(data.data(), data.size())}}; if (const unsigned count = itemstack::StackCount(id); count > 1) { j["count"] = count; } return j; } // Recreates an item as loadHero does; returns its new id, 0 on failure. uint32_t CreateItem(const json& j, uint32_t heroId) { const std::string hex = j.at("data"); std::vector data(hex.size() / 2); game::Unhex(hex, data.data(), data.size()); return game::CreateItem(j.at("type"), data, heroId); } void Load() { ClearGrid(); std::ifstream file(FilePath(), std::ios::binary); if (!file) { re::Log("shared chest: no %s yet", FilePath().c_str()); return; } void* hero = game::GetPlayerHero(); const auto heroId = hero ? static_cast(game::CreatureField(hero, game::kCreatureId)) : 0u; int loaded = 0; try { const json j = json::parse(file); using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); for (const json& entry : j.at("items")) { const uint32_t id = CreateItem(entry, heroId); if (!id) { re::Log("shared chest: item of type %#x not recreated", entry.at("type").get()); continue; } reinterpret_cast(0x005d9ec0)(Shared(), nullptr, entry.at("cell"), id, entry.at("w"), entry.at("h"), 0); // cInventoryEntry::PlaceItem if (entry.contains("count")) { itemstack::SetStackCount(id, entry.at("count")); } ++loaded; } } catch (const std::exception& e) { re::Log("shared chest: %s unreadable (%s)", FilePath().c_str(), e.what()); } re::Log("shared chest: %d items loaded", loaded); } // A session started or a save game was loaded into it (its objects replace // the old ones): (re)load on the next tick with a hero. void RequestLoad() { g_loadPending = true; } // The chest grid's place and size, as the cUI_Chest constructor sets them for // inventory 0x11: (window x, 20), 20 x 10. Loads overwrite the place of 0x11 // (the game's inventory read resets some to 0, the JSON inventories restore the // saved one), so both tabs are put back every tick. constexpr size_t kWindowX = 0x24; // i16 constexpr uint16_t kGridY = 20; constexpr uint16_t kChestColumns = 0x14; constexpr uint16_t kChestRows = 0xa; void PlaceGrid(void* window, uint8_t* inventory) { *reinterpret_cast(inventory + kGridOriginX) = *reinterpret_cast(static_cast(window) + kWindowX); *reinterpret_cast(inventory + kGridOriginY) = kGridY; *reinterpret_cast(inventory + game::kGridWidth) = kChestColumns; *reinterpret_cast(inventory + game::kGridHeight) = kChestRows; } void PlaceGrids() { if (g_chestWindow) { PlaceGrid(g_chestWindow, GameInventory(game::kChestInventory)); PlaceGrid(g_chestWindow, Shared()); } } void OnTick(uint8_t*) { if (g_loadPending && game::GetPlayerHero()) { g_loadPending = false; g_loaded = true; Load(); } PlaceGrids(); } void OnGameEnd() { if (g_constructed) { ClearGrid(); } g_loaded = false; g_loadPending = false; g_chestWindow = nullptr; // set again when the next session builds its chest window g_sheet = nullptr; } // The inventory window's tab headers (Rucksack / Kampfkünste / Combos, AddTab at // 0x6c63ab..0x6c63d5) are upper case in the text resource; capitalized like the // chest's (request 2026-10-17). constexpr uint32_t kInventoryTabTexts[] = {0x483, 0x484, 0x485}; struct Registration { Registration() { game::OnEngineTick(&OnTick); game::OnGameStart(&RequestLoad); game::OnSaveGameLoaded(&RequestLoad); game::OnGameEnd(&OnGameEnd); for (const uint32_t id : kInventoryTabTexts) { game::CapitalizeText(id); } } } g_registration; // --- tabs ---------------------------------------------------------------------- // A cUI_TabSheet of the game's own (the inventory's header look) in the chest // window, its headers stacked in a column left of the grid's top-left corner. constexpr int kHeaderGap = 2; void ShowTab(void* window, bool shared) { uint8_t* chest = GameInventory(game::kChestInventory); PlaceGrids(); *reinterpret_cast(static_cast(window) + kWindowInventory) = shared ? Shared() : chest; } // The headers as a column: same width, right edge at the sheet's left edge. void LayOutColumn(void* sheet) { uint8_t* control = game::TabControl(sheet); if (!control) { return; } uint8_t* entries = game::TabEntries(control); const size_t count = game::TabCount(control); int width = 0; int height = *reinterpret_cast(game::kTabHeaderArtHeight); for (size_t i = 0; i < count; ++i) { const auto* rect = reinterpret_cast(entries + i * game::kTabEntrySize + game::kTabEntryRect); width = std::max(width, rect->w); height = std::max(height, rect->h); } for (size_t i = 0; i < count; ++i) { uint8_t* entry = entries + i * game::kTabEntrySize; auto* rect = reinterpret_cast(entry + game::kTabEntryRect); rect->x = -width - kHeaderGap; rect->y = static_cast(i) * (height + kHeaderGap); rect->w = static_cast(width); if (auto* header = *reinterpret_cast(entry + game::kTabEntryHeader)) { *reinterpret_cast(header + game::kControlRect) = *rect; } } } // Built right after the chest window (cUI_Chest constructor). void CreateTabs(void* window) { static const uint32_t kNames[2] = {game::RegisterText(L"Truhe"), game::RegisterText(L"Gemeinsam")}; g_chestWindow = window; g_sheet = game::NewTabSheet("UI_TABSHEET_CHEST", {0, kGridY, 0, 0}, window); if (!g_sheet) { return; } // Pages: empty controls (the grid stays the chest's own; the tab only // switches the inventory behind it). static const char* const kPageNames[2] = {"UI_TABSHEET_CHEST_HERO", "UI_TABSHEET_CHEST_SHARED"}; for (int tab = 0; tab < 2; ++tab) { game::AddTab(g_sheet, kNames[tab], game::NewTabSheet(kPageNames[tab], {0, 0, 0, 0}, g_sheet)); } game::SetActiveTab(g_sheet, 0); game::AddWindowControl(window, g_sheet); } // Moves `id` from `from` into free space in `to` (as MoveItem does). bool MoveBetween(uint8_t* from, uint8_t* to, uint32_t id) { const unsigned cells = game::GridCells(from); for (unsigned cell = 0; cell < cells; ++cell) { if (game::GridItem(from, cell) != id) { continue; } const uint8_t* entry = from + game::kInventoryGrid + cell * game::kGridCellSize; const uint32_t w = entry[kCellWidth]; const uint32_t h = entry[kCellHeight]; uint16_t target = 0; using FindFn = char(__fastcall*)(void*, void*, uint32_t, uint32_t, uint16_t*, uint32_t); if (!reinterpret_cast(0x005dab60)(to, nullptr, w, h, &target, 0)) { // FindFreeSpace return false; } using RemoveFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t); using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); reinterpret_cast(0x005da5f0)(from, nullptr, id, 1); // RemoveItem reinterpret_cast(0x005d9ec0)(to, nullptr, target, id, w, h, 1); // PlaceItem return true; } return false; } } // namespace // Called after the game saved a hero (inventory_save.cpp, ItemDataMgr::saveHero). void Save() { if (!g_loaded || !g_constructed) { return; } json items = json::array(); const uint8_t* shared = Shared(); const unsigned cells = game::GridCells(shared); for (unsigned cell = 0; cell < cells; ++cell) { if (const uint32_t id = game::GridItem(shared, cell)) { json item = ItemJson(shared, cell, id); if (!item.is_null()) { items.push_back(std::move(item)); } } } const json j = {{"format", "sacredK.shared_chest"}, {"version", kFileVersion}, {"items", items}}; const std::string path = FilePath(); const std::string temp = path + ".tmp"; { std::ofstream file(temp, std::ios::binary | std::ios::trunc); file << j.dump(1); if (!file) { re::Log("shared chest: writing %s failed", temp.c_str()); return; } } if (!MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) { re::Log("shared chest: replacing %s failed (%lu)", path.c_str(), GetLastError()); return; } re::Log("shared chest: %u items saved", static_cast(items.size())); } } // namespace mods::sharedchest namespace game { using namespace mods::sharedchest; // cUI_Chest::cUI_Chest (0x6ac7f0, ret 0x18): (name, rect, parent, flags); then // the tabs. void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent, uint32_t flags); RE_HOOK(0x006ac7f0, UiChest_Construct); void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent, uint32_t flags) { void* window = UiChest_Construct_original(self, edx, name, rect, parent, flags); if (window) { CreateTabs(window); re::Log("shared chest: tabs created"); } return window; } // cUI_TabSheet layout (0x729210, plain ret): headers in a row; ours in a column. void __fastcall TabSheet_Layout(void* self, void* edx); RE_HOOK(0x00729210, TabSheet_Layout); void __fastcall TabSheet_Layout(void* self, void* edx) { TabSheet_Layout_original(self, edx); if (self && self == g_sheet) { LayOutColumn(self); } } // cUI_TabControl::SetActive (0x728af0, ret 4): our tabs switch the chest's inventory. void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index); RE_HOOK(0x00728af0, TabControl_SetActive); void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index) { TabControl_SetActive_original(self, edx, index); if (g_sheet && g_chestWindow && self == TabControl(g_sheet)) { ShowTab(g_chestWindow, (index & 0xffff) == 1); } } // cInventoryManager::MoveItem (0x5dec80, ret 0x10): (from, to, id, ?). Shift+click // in the chest moves from 0x11; with the shared tab shown, from the shared chest. char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net); RE_HOOK(0x005dec80, InvManager_MoveItem); char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net) { if (SharedOpen() && (from & 0xffff) == kChestInventory && (to & 0xffff) < 0x20) { return MoveBetween(Shared(), GameInventory(to & 0xffff), id) ? 1 : 0; } if (SharedOpen() && (to & 0xffff) == kChestInventory && (from & 0xffff) < 0x20) { return MoveBetween(GameInventory(from & 0xffff), Shared(), id) ? 1 : 0; } return InvManager_MoveItem_original(self, edx, from, to, id, net); } } // namespace game