Files
sacred_k/src/mods/sharedchest.cpp
T
khallaandClaude Opus 5.5 6ca96d54b4 Shared chest tabs, rune stacks and rune master, Q pickup, Kettenblitz tooltip and range
- Shared chest: tabs "Truhe"/"Gemeinsam" as a column left of the grid
  (game tab sheet), grids placed every tick, persistent across heroes
- Inventory tab texts capitalized (RegisterText / CapitalizeText)
- Combat-art runes stack like potions (itemstack, renamed from potionstack)
- Rune master: any rune for any number of runes, price by rune count
  (4 runes = 1,000, x10 per missing rune), bought rune goes into the
  backpack, Buy disabled without runes, gold or room, last rune remembered
  per hero (saved), the chosen rune's name below its icon, dropping a
  stack gives up one rune
- Keys: the old key handling no longer takes over after the save screen
  (text field focus only counts while its dialog is shown)
- Esc crash: tooltips forgotten when the game destroys them
- Pick up (Q) sends the pickup order directly; search reach doubled
- Kettenblitz: tooltip shows hits per chain, chains, cast and jump range;
  cast range 900 (longer ray segments), jumps half of that

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 03:40:42 +02:00

411 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
// save\shared_chest.json: 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 "re/config.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() {
std::string dir = re::ConfigString("paths", "GameDir", "");
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
dir += '\\';
}
return dir + "save\\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", "sacred_re.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