Files
sacred_k/src/mods/sharedchest.cpp
T
khallaandClaude Opus 5.5 6c467e22c4 User folders: save games and Settings.cfg in Documents\Sacred
The game's ANSI file API calls are hooked; paths under save\ and
Settings.cfg are redirected to Documents\Sacred\save and
Documents\Sacred\config, copied there once from the game folder.
The shared chest uses the same save folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:14:36 +02:00

407 lines
15 KiB
C++

// 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 <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
#include <windows.h>
#include <nlohmann/json.hpp>
#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<ConstructFn>(0x005d7a10)(g_storage, nullptr, 0, 0, 0x14, 0xa, 0); // cInventoryEntry::Construct
*reinterpret_cast<uint16_t*>(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<uint8_t**>(static_cast<uint8_t*>(g_chestWindow) + kWindowInventory) == Shared();
}
bool Visible(void* window) {
return (*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(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<uint8_t> 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<uint8_t> 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<uint32_t>(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<uint32_t>());
continue;
}
reinterpret_cast<PlaceFn>(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<uint16_t*>(inventory + kGridOriginX) =
*reinterpret_cast<const uint16_t*>(static_cast<uint8_t*>(window) + kWindowX);
*reinterpret_cast<uint16_t*>(inventory + kGridOriginY) = kGridY;
*reinterpret_cast<uint16_t*>(inventory + game::kGridWidth) = kChestColumns;
*reinterpret_cast<uint16_t*>(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<uint8_t**>(static_cast<uint8_t*>(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<const int16_t*>(game::kTabHeaderArtHeight);
for (size_t i = 0; i < count; ++i) {
const auto* rect = reinterpret_cast<game::UiRect*>(entries + i * game::kTabEntrySize + game::kTabEntryRect);
width = std::max<int>(width, rect->w);
height = std::max<int>(height, rect->h);
}
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = entries + i * game::kTabEntrySize;
auto* rect = reinterpret_cast<game::UiRect*>(entry + game::kTabEntryRect);
rect->x = -width - kHeaderGap;
rect->y = static_cast<int32_t>(i) * (height + kHeaderGap);
rect->w = static_cast<int16_t>(width);
if (auto* header = *reinterpret_cast<uint8_t**>(entry + game::kTabEntryHeader)) {
*reinterpret_cast<game::UiRect*>(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<FindFn>(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<RemoveFn>(0x005da5f0)(from, nullptr, id, 1); // RemoveItem
reinterpret_cast<PlaceFn>(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<unsigned>(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