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>
407 lines
15 KiB
C++
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
|