diff --git a/CMakeLists.txt b/CMakeLists.txt index 1be13a8..4c1c37b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,9 @@ add_library(sacred_re SHARED src/game/engine/events.cpp src/game/engine/fx.cpp src/game/engine/orders.cpp + src/game/engine/items.cpp + src/game/engine/stream.cpp + src/game/ui/texts.cpp src/mods/borderless.cpp src/mods/widescreen.cpp src/mods/hud.cpp @@ -44,7 +47,8 @@ add_library(sacred_re SHARED src/mods/skillslots.cpp src/mods/inventory_save.cpp src/mods/potioncounts.cpp - src/mods/potionstack.cpp + src/mods/itemstack.cpp + src/mods/sharedchest.cpp src/mods/skillbar.cpp src/mods/arttarget.cpp src/mods/aura_restore.cpp @@ -57,6 +61,7 @@ add_library(sacred_re SHARED src/mods/recordcache.cpp src/mods/megamap.cpp src/mods/respec.cpp + src/mods/runetrade.cpp src/mods/guards.cpp ) target_include_directories(sacred_re PRIVATE src) diff --git a/docs/chain-lightning.md b/docs/chain-lightning.md index 416c83e..ab120e2 100644 --- a/docs/chain-lightning.md +++ b/docs/chain-lightning.md @@ -67,3 +67,27 @@ ray reaches it, otherwise the visible ray just stops. So the cast range is (also the first hop of extra chains), instead of the game's jump search radius (squared 100000, about 316). It is the one place to make the range dynamic later. Jumps do not check the ground in between. + +## Tooltip (2026-10-17) + +Request: the tooltip should match the mod. The game's text (cCreatureArts::ArtTooltip +0x589270, measured for art 0x21 at level 5) ends each level section with +"Anzahl: X", the game's own hit count. The mod replaces that line with +"Treffer je Kette" (3 + L/5), "Ketten" (1 + L/10) and "Sprungweite" (the cast +range), with L = the hero's level (base + bonus) in the "Aktuelle Stufe" section +and L + 1 in "Nächste erlernte Stufe". The string is rewritten with the game's +append (0x592ef0), so the game's allocator owns the buffer. + +## Range +50 % (2026-10-17) + +Request: Kettenblitz range about 50 % larger. The ray's 20 segments are +bounded by the caster's segment id array (+0x263, 20 dwords), so the segments +get longer instead: 30 -> 45, cast and jump range 600 -> 900. Patched in the +art code: `fsub [30.0]` 0x526db6 and `fcomp [30.0]` 0x526e0b (redirected to +our 45.0; 0x88ef18 is shared by 72 instructions), `mov eax,30` 0x526e23 (the +segment length given to each segment object) and `push 30.0f` 0x526f3d (the +step of the ray point). + +Follow-up (2026-10-17): jumps at 900 felt too far. The cast keeps 900; jumps +use `JumpRange()` = half the cast range (450). The tooltip shows both +("Reichweite", "Sprungweite"). diff --git a/docs/escape.md b/docs/escape.md index 554dabc..04d3c8e 100644 --- a/docs/escape.md +++ b/docs/escape.md @@ -37,3 +37,10 @@ every player hero a cEvent_creature 0x10c (ends the input lock) and passes answers below 100000 to the quest script (0x46bd80). Esc does the same with answer 0xffffffff: the dialog closes, nothing is chosen. (Hiding the tooltip alone left the input lock; measured with a write watch on its flags.) + +## Crash fix (2026-10-17) + +Esc crashed in `CloseDialog` on a tooltip pointer whose object had been +destroyed (its vtable was already the base class's; the hide call jumped to +garbage). The HUD layer's tooltip list (`g_tooltips`, hud.cpp) kept destroyed +tooltips; `cUI_Tooltip::~cUI_Tooltip` (`0x6e6560`) is hooked now and drops them. diff --git a/docs/potion-stacks.md b/docs/item-stacks.md similarity index 69% rename from docs/potion-stacks.md rename to docs/item-stacks.md index c17ab55..0d3a464 100644 --- a/docs/potion-stacks.md +++ b/docs/item-stacks.md @@ -1,4 +1,4 @@ -# Potion counts and potion stacks +# Item stacks (potions, combat-art runes) ## Requests (2026-10-17) @@ -44,7 +44,7 @@ Not seen yet: a sale, a right-click drink. - `src/mods/potioncounts.cpp`: counts per kind (stacks added up), drawn bottom-right on each potion slot after `RenderPotionSlots`. -- `src/mods/potionstack.cpp`: stack counts by item id (with the item type), +- `src/mods/itemstack.cpp`: stack counts by item id (with the item type), saved as `"count"` on the grid cell in the JSON inventories (`inventory_save.cpp`); entry pours into stacks with room on the next tick; `Sort` stacks the hero's potions first; a drink from a stack of more @@ -67,3 +67,29 @@ JSON path already (stack sizes survived a reload). Stacking now applies to all inventories below 0x12 (players and the chest); trader stock (0x12 and up) never stacks. Selling a stack to a trader still has to be handled (pay count x price, the trader keeps singles). + +## Runes (request 2026-10-17: "also allow stacking for combat arts") + +- Measured (sort button log of a Rucksack with runes): runes are item class + 0x1c (type table +0x2e), 1x1. Two runes of type 0x1517 serialize the same + past the object part (id, flags, world position differ), so the type alone + identifies a rune. +- The game's own `ItemIsConsumable` (`0x43f5f0`, class 9 or 0x1c) now decides + what stacks (was: `ItemPotionKind`). The module was renamed + `src/mods/itemstack.cpp`. +- Reading a rune goes through `cCreature::UseItem` like a potion (class 0x1c + is one of the three classes it uses), so a stack loses one per read. + +## Rune master (request 2026-10-17: one rune per drop) + +(See docs/rune-master.md for the window.) `cUI_MasterLotto::HandleEvent` +(`0x6d54e0`, read): a left press over one of +its four slots (controls at +0x154 + i * 0x114) puts the slot's old rune on +the cursor and, if the cursor carries a rune (descriptor at cursor +0x68: +{u16 kind 1, u16 class 0x1c, u32 id, ...}), copies the descriptor into the +slot (+0x24c + i * 0x114) and clears the cursor. `src/mods/runetrade.cpp` +(owns the event hook; hud.cpp centres it through `hud::HandleCentred`): for a +stack, a single copy (`game::CloneItem`, serializer round trip) goes into the +slot, the stack keeps count - 1 and stays on the cursor; if the slot held a +rune (it comes onto the cursor), the rest goes to the backpack instead, and +without room the click does nothing. diff --git a/docs/rune-master.md b/docs/rune-master.md new file mode 100644 index 0000000..ff4ef8a --- /dev/null +++ b/docs/rune-master.md @@ -0,0 +1,54 @@ +# Rune master (`src/mods/runetrade.cpp`) + +## Requests (2026-10-17) + +1. Dropping a rune stack into the rune master takes one rune off the stack. +2. Buying: runes can always be exchanged directly for a chosen rune; the + number of runes given lowers the price (decided: 4 runes 1,000, each + missing rune x10: 3 runes 10,000, 2 runes 100,000, 1 rune 1,000,000). The + rune to buy can always be chosen; Buy works only with at least one rune + and enough gold. The last rune bought is remembered (decided: per hero, + saved). + +## The original window (`cUI_MasterLotto`, read) + +| Offset | What | +|---|---| +| +0x154 + i * 0x114 | slot control i (0..3), hit test vf1c(x, y) | +| +0x24c + i * 0x114 | slot i's item descriptor {u16 kind 1, u16 class 0x1c, u32 id, ...} | +| +0x5a4 / +0x620 | arrows: next / previous rune (`doLottoSelect` mode 1 / 0) | +| +0x69c | the bought rune's control; +0x794 its descriptor, +0x798 id, +0x79c type | +| +0x7b4 | the chosen rune (item type) | +| +0x7c8 | Buy button | + +- Prices: u32 table at `0xad2aac` by rune count; the game fills 2..4 from + its settings (`0x5efc7b`); read by the render, the button state + (`0x6d4390`, `0x6d3df0`) and the pay helper `0x6d3ce0(pay)` (gold at + hero +0x3ee, paid with `cCreatureArts::AddGold` `0x57fc40`). +- Buy (`0x6d42a0`): 2 runes give a random rune, 3 a random one of the hero's + class, 4 the chosen one. Buying needed 2 runes (`cmp dx, 2` at `0x6d3e20`, + `0x6d4544`, `0x6d4a13`). +- `0x6d3d50` after slot changes: with 4 runes shows the arrows and keeps the + choice (`doLottoSelect(hero type, 2)`, index in the global `0x17e81d0`), + otherwise hides them and clears +0x7b4. + +## Mod + +- Owns the window's event hook (hud.cpp centres it via `hud::HandleCentred`). +- Prices: `kPrices` written into the table (on game start and with each + window event); the three `cmp dx, 2` become 1. +- `0x6d3d50` replaced: arrows always shown (unless a bought rune waits), a + rune always chosen; when none is chosen (window opened) the hero's last + bought rune is chosen by stepping `doLottoSelect` forward to it. +- Buy press handled here (`CanBuy`): at least one rune, a chosen rune, gold + (`0x6d3ce0(0)`) and room in the backpack (a stack of that rune with room, + or a free cell). Buying pays (`0x6d3ce0(1)`), destroys the slot runes and + puts the rune straight into the backpack (follow-up 2026-10-17: a bought + rune taken from the window vanished; the window's waiting slot +0x794 / + +0x798 is no longer used). The rune is remembered for the hero. +- The Buy button looks disabled whenever `CanBuy` fails: the render sets its + colours each frame with `cUI_Button::SetColours` (`0x6d5d90`), hooked to + hand it the disabled set (`0x9f0290`, text `0x9f028c`). +- Remembered rune: `"hero": {"lastRune": type}` in the hero's inventory JSON + (`inventory_save::RegisterHeroData`); cleared when the session ends. +- Stack drops: see docs/item-stacks.md. diff --git a/docs/shared-chest.md b/docs/shared-chest.md new file mode 100644 index 0000000..1c812b2 --- /dev/null +++ b/docs/shared-chest.md @@ -0,0 +1,71 @@ +# Shared chest + +## Request (2026-10-17) + +The chest gets a second tab that belongs to no single hero: a chest shared by +all characters. Decided: stored in one file in the save folder +(`save\shared_chest.json`), two tabs ("Truhe" / "Gemeinsam"), same size as the +chest (20 x 10). Potions stack in it like in every player inventory +(docs/item-stacks.md). Follow-up (same day): text tabs were barely visible; +use the inventory's Rucksack / Kampfkünste / Combos header look with its +selected state, stacked as a column from the grid's top-left. The shared chest +was empty after loading another character (in-game saves don't call saveHero). + +## Measured / read + +- The chest window `cUI_Chest` (constructor ends `0x6ac899`): +0x158 = + inventory 0x11 (the hero's chest), grid 20 x 10 from (window x, 20). Render + `0x6ac8b0` (vf14) draws +0x158 with `DrawItems`; HandleEvent `0x6acc70` + (vf10) does hit test, fit, place, swap and take via +0x158. Only Shift+click + uses the index: `cInventoryManager::MoveItem` `0x5dec80` (from, to, id, ?, + ret 0x10) from 0x11 to the hero. +- Chest saving: inventory 0x11 is chunk 0xc6 of the hero file and part of + save games, both through the JSON inventories already (diagnostic log). +- Items in hero files (`ItemDataMgr::saveHero` `0x6046f0`, chunk 0xc2): the + ids of both inventories, equipment, weapon sets; per item its type (+0x10) + and the object's serializer `vf1c(stream, 1, 0)` (ret 0xc). +- Loading (`loadHero` `0x605900`, chunk 0xc2): `cObjectManager::Create(type, + 0, 0, 0)` `0x5fb590`, `vf1c(stream, 0, version)` (version = header byte, + 0x1b), then the saved id (read into +0xc) is replaced by the new one + (`ReplaceItemId`), flags +0x14: clear 0x200000, 0x30 if bit 2, 0x10; +0x39 + = owner hero id. +- `cEngine::~cEngine` `0x60d5e0` (plain ret) runs before the object manager is + destroyed: `game::OnGameEnd`. + +## Mod (`src/mods/sharedchest.cpp`) + +- An inventory of our own (`cInventoryEntry::Construct` `0x5d7a10`, owner + 0x11 so it counts as a player inventory). +- Tabs: a `cUI_TabSheet` (constructor `0x728ed0`, `AddTab` `0x7290d0`) built + right after the chest window (`cUI_Chest::cUI_Chest` `0x6ac7f0`), the same + control as the inventory's Rucksack / Kampfkünste / Combos headers, so the + selected / hover look is the game's. Its layout (`0x729210`, headers in a row + above the sheet) is followed by ours: a column left of the grid's top-left + corner. Labels "Truhe" / "Gemeinsam" through `game::RegisterText` + (`src/game/ui/texts.cpp`, an override of `GetText` `0x672f70`). Switching + (`cUI_TabControl::SetActive` `0x728af0`) sets window +0x158 to inventory + 0x11 or the shared one (origin and size copied from the chest). Pages are + empty controls. +- The tab sheet is added to the window's control list (`cUI_Window2::AddControl` + `0x727120`); only those controls are drawn and get events (a parent alone + isn't enough). +- Grid place: the constructor sets inventory 0x11 to (window x, 20), 20 x 10 + once, but loads overwrite it (the game's inventory read `0x5df09b` resets + some inventories to 0/0; the JSON inventories restore the saved place), so + the chest drew at the wrong spot after some loads. Both tabs' grids are put + back to the constructor's values every tick and on each tab switch. +- `MoveItem` from/to 0x11 while the shared tab is open moves from/to it. +- File: `{"format": "sacred_re.shared_chest", "version": 1, "items": [{cell, + w, h, type, data (hex of vf1c), count}]}`; written after each successful + `saveHero` and after each save-game write (in-game saves don't call saveHero; + via a temp file), read on the first engine tick with a hero after a session + start (`game::OnGameStart`, cEngine::cEngine `0x60ac70`) or a save-game read + with a hero; the grid is cleared when the session ends. + +## Open / to check + +- Whether save games (PAK) also store the shared chest's item objects (they + live in the object manager outside the 32 inventories). +- loadHero's extra fix-up for some item classes (`0x5beaa0` / `0x5d3a20`, + item classes 5, 6, 8, 0xd, 0x11..0x19, 0x1b, 0x1d) is not repeated. +- Multiplayer: not supported. diff --git a/docs/todo.md b/docs/todo.md index 2d2844e..fcda16e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -119,7 +119,7 @@ in-game texts/tooltips that name the old keys): | Key | Action | Temporary key (sacred_re.ini) | |---|---|---| | W | action slot 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | G (`[keys] ActionSlot3`) | -| A | pick up items nearby (creature event 0x113) | V (`[keys] PickUp`) | +| A | pick up items nearby (creature event 0x113) | Q (`[keys] PickUp`, 2026-10-17; was V) | | S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) | | D | unbound | - | @@ -133,4 +133,4 @@ Asked 2026-10-17 (after the potion counts on the bottom bar, stack in one inventory cell, up to 20 per stack (a full stack starts a new one). Drag, sell and drop move the whole stack, as with any other item. The counts on the potion slots then add up the stacks. In progress, see -docs/potion-stacks.md (open: sale price per stack, splitting on the ground). +docs/item-stacks.md (open: sale price per stack, splitting on the ground). diff --git a/src/game/engine/events.cpp b/src/game/engine/events.cpp index 8be8e5c..107f307 100644 --- a/src/game/engine/events.cpp +++ b/src/game/engine/events.cpp @@ -17,6 +17,16 @@ std::vector& LoadedCallbacks() { return callbacks; } +std::vector& GameEndCallbacks() { + static std::vector callbacks; + return callbacks; +} + +std::vector& GameStartCallbacks() { + static std::vector callbacks; + return callbacks; +} + } // namespace void OnEngineTick(EngineTickFn callback) { @@ -27,6 +37,14 @@ void OnSaveGameLoaded(SaveGameLoadedFn callback) { LoadedCallbacks().push_back(callback); } +void OnGameEnd(GameEndFn callback) { + GameEndCallbacks().push_back(callback); +} + +void OnGameStart(GameStartFn callback) { + GameStartCallbacks().push_back(callback); +} + void NotifySaveGameLoaded() { for (SaveGameLoadedFn callback : LoadedCallbacks()) { callback(); @@ -46,4 +64,26 @@ void __fastcall Engine_Tick(void* self, void* edx, void* event) { } } +// cEngine::cEngine (0x60ac70, ret 4, called once at 0x60d7a3): the save game +// and the hero are loaded inside it. +void* __fastcall Engine_Construct(void* self, void* edx, uint32_t arg); +RE_HOOK(0x0060ac70, Engine_Construct); +void* __fastcall Engine_Construct(void* self, void* edx, uint32_t arg) { + void* result = Engine_Construct_original(self, edx, arg); + for (GameStartFn callback : GameStartCallbacks()) { + callback(); + } + return result; +} + +// cEngine::~cEngine (0x60d5e0, plain ret). +void __fastcall Engine_Destruct(void* self, void* edx); +RE_HOOK(0x0060d5e0, Engine_Destruct); +void __fastcall Engine_Destruct(void* self, void* edx) { + for (GameEndFn callback : GameEndCallbacks()) { + callback(); + } + Engine_Destruct_original(self, edx); +} + } // namespace game diff --git a/src/game/engine/events.h b/src/game/engine/events.h index da21abb..512526f 100644 --- a/src/game/engine/events.h +++ b/src/game/engine/events.h @@ -16,4 +16,14 @@ using SaveGameLoadedFn = void (*)(); void OnSaveGameLoaded(SaveGameLoadedFn callback); void NotifySaveGameLoaded(); +// The game session ends (cEngine::~cEngine 0x60d5e0, before the engine and the +// object manager are torn down): object ids are about to become invalid. +using GameEndFn = void (*)(); +void OnGameEnd(GameEndFn callback); + +// A game session started (cEngine::cEngine 0x60ac70 returned; the save game +// and the hero are loaded by then). +using GameStartFn = void (*)(); +void OnGameStart(GameStartFn callback); + } // namespace game diff --git a/src/game/engine/items.cpp b/src/game/engine/items.cpp new file mode 100644 index 0000000..e593187 --- /dev/null +++ b/src/game/engine/items.cpp @@ -0,0 +1,51 @@ +#include "game/engine/items.h" + +#include "game/engine/stream.h" + +namespace game { +namespace { + +using SerializeFn = uint32_t(__fastcall*)(void* item, void* edx, Stream*, uint32_t write, uint32_t version); + +SerializeFn ItemSerializer(void* item) { + return reinterpret_cast((*static_cast(item))[0x1c / 4]); +} + +} // namespace + +std::vector SerializeItem(void* item) { + std::vector data; + MemoryStream memory = MakeMemoryStream(data); + ItemSerializer(item)(item, nullptr, AsStream(memory), 1, 0); + return data; +} + +uint32_t CreateItem(uint32_t type, const std::vector& data, uint32_t ownerHeroId) { + using CreateFn = uint32_t(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t); + void* manager = ObjectManager(); + const uint32_t id = manager ? reinterpret_cast(0x005fb590)(manager, nullptr, type, 0, 0, 0) : 0; + auto* item = static_cast(id ? GetObjectById(id) : nullptr); // cObjectManager::Create + if (!item) { + return 0; + } + std::vector copy = data; // the stream reads from a vector it may touch + MemoryStream memory = MakeMemoryStream(copy); + ItemSerializer(item)(item, nullptr, AsStream(memory), 0, kItemFileVersion); + // As loadHero after reading an item: the new id, flags, the owner. + auto& flags = *reinterpret_cast(item + kObjectFlags); + *reinterpret_cast(item + 0xc) = id; + flags &= ~0x200000u; + if (flags & 2) { + flags &= ~0x30u; + } + flags &= ~0x10u; + item[kItemOwner] = static_cast(ownerHeroId); + return id; +} + +uint32_t CloneItem(uint32_t id) { + auto* item = static_cast(id ? GetObjectById(id) : nullptr); + return item ? CreateItem(ItemType(item), SerializeItem(item), item[kItemOwner]) : 0; +} + +} // namespace game diff --git a/src/game/engine/items.h b/src/game/engine/items.h index d16285e..c46f4bc 100644 --- a/src/game/engine/items.h +++ b/src/game/engine/items.h @@ -24,6 +24,28 @@ inline int ItemPotionKind(uint32_t type) { return table ? reinterpret_cast(0x0043f630)(table, nullptr, type) : 0; } +// The item class of item type `type` (type table entry: 0x80 bytes, class u8 +// at +0x2e; read by ItemPotionKind). Usable from the inventory +// (cCreature::UseItem 0x54a8f0): 9 (potions), 0x10, 0x1c (runes). +constexpr size_t kItemTypeEntrySize = 0x80; +constexpr size_t kItemTypeClass = 0x2e; +inline uint8_t ItemClass(uint32_t type) { + const auto* table = *reinterpret_cast(kItemTypeTable); + return table ? table[type * kItemTypeEntrySize + kItemTypeClass] : 0; +} + +// Item classes (type table +0x2e), measured: 5 weapons / armour, 7, 9 potions, +// 0x1c combat-art runes (1x1). +constexpr uint8_t kItemClassPotion = 9; +constexpr uint8_t kItemClassRune = 0x1c; + +// 0x43f5f0 (stdcall, ret 4): the item type is a potion or a rune (class 9 or +// 0x1c). Items of one such type are alike apart from their object state +// (measured: two runes of one type serialize the same past the object part). +inline bool ItemIsConsumable(uint32_t type) { + return reinterpret_cast(0x0043f5f0)(type); +} + inline uint32_t ItemType(const void* item) { return *reinterpret_cast(static_cast(item) + kItemType); } @@ -74,3 +96,25 @@ void ForEachGridItem(const uint8_t* inventory, Fn fn) { } } // namespace game + +#include + +namespace game { + +// Item objects as hero files store them (ItemDataMgr::saveHero / loadHero, +// docs/shared-chest.md): the type plus the object's own serializer +// vf1c(stream, write, version), ret 0xc. +constexpr uint32_t kItemFileVersion = 0x1b; // the hero file version handed to the item readers +constexpr size_t kItemOwner = 0x39; // u8: the owner hero's id + +std::vector SerializeItem(void* item); + +// A new item object of `type` read from `data`, as loadHero recreates one +// (cObjectManager::Create, the serializer, its new id, flags, the owner). +// Returns its id, 0 on failure. +uint32_t CreateItem(uint32_t type, const std::vector& data, uint32_t ownerHeroId); + +// A copy of item `id` as a new object (same type and data, same owner). +uint32_t CloneItem(uint32_t id); + +} // namespace game diff --git a/src/game/engine/stream.cpp b/src/game/engine/stream.cpp new file mode 100644 index 0000000..829fcee --- /dev/null +++ b/src/game/engine/stream.cpp @@ -0,0 +1,72 @@ +#include "game/engine/stream.h" + +#include +#include + +namespace game { +namespace { + +void* __fastcall MemDestroy(MemoryStream* self, void*, int) { + return self; +} +uint32_t __fastcall MemRead(MemoryStream* self, void*, void* buffer, uint32_t size) { + const uint32_t available = static_cast(self->data->size()) - self->position; + const uint32_t n = size < available ? size : available; + std::memcpy(buffer, self->data->data() + self->position, n); + self->position += n; + return n; +} +uint32_t __fastcall MemWrite(MemoryStream* self, void*, const void* buffer, uint32_t size) { + if (self->data->size() < self->position + size) { + self->data->resize(self->position + size); + } + std::memcpy(self->data->data() + self->position, buffer, size); + self->position += size; + return size; +} +void __fastcall MemSeek(MemoryStream* self, void*, uint32_t position) { + self->position = position; +} +uint32_t __fastcall MemSize(MemoryStream* self, void*) { + return static_cast(self->data->size()); +} +uint32_t __fastcall MemTell(MemoryStream* self, void*) { + return self->position; +} + +void* g_memoryVtable[8] = { + reinterpret_cast(&MemDestroy), reinterpret_cast(&MemRead), + reinterpret_cast(&MemWrite), reinterpret_cast(&MemSeek), + reinterpret_cast(&MemSize), reinterpret_cast(&MemTell), + reinterpret_cast(0x0065afb0), reinterpret_cast(0x0065af80), // cFile helpers +}; + +} // namespace + +MemoryStream MakeMemoryStream(std::vector& data) { + return MemoryStream{g_memoryVtable, &data, 0}; +} + +std::string Hex(const uint8_t* p, size_t size) { + static const char digits[] = "0123456789abcdef"; + std::string s(size * 2, '0'); + for (size_t i = 0; i < size; ++i) { + s[2 * i] = digits[p[i] >> 4]; + s[2 * i + 1] = digits[p[i] & 15]; + } + return s; +} + +void Unhex(const std::string& s, uint8_t* out, size_t size) { + if (s.size() != size * 2) { + throw std::runtime_error("hex field has the wrong length"); + } + auto nibble = [](char c) -> uint8_t { + return static_cast(c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10); + }; + for (size_t i = 0; i < size; ++i) { + out[i] = static_cast(nibble(s[2 * i]) << 4 | nibble(s[2 * i + 1])); + } +} + +} // namespace game diff --git a/src/game/engine/stream.h b/src/game/engine/stream.h new file mode 100644 index 0000000..f3f5109 --- /dev/null +++ b/src/game/engine/stream.h @@ -0,0 +1,50 @@ +#pragma once +// The game's file interface (cFile) and an in-memory cFile, for code that +// lets the game (de)serialize into a buffer: inventories, item objects. + +#include +#include +#include + +namespace game { + +// cFile: vf04 read, vf08 write, vf0c seek, vf10 size, vf14 tell; all __thiscall. +struct Stream { + void** vtable; +}; + +inline uint32_t Read(Stream* stream, void* buffer, uint32_t size) { + using Fn = uint32_t(__fastcall*)(Stream*, void*, void*, uint32_t); + return reinterpret_cast(stream->vtable[1])(stream, nullptr, buffer, size); +} +inline uint32_t Write(Stream* stream, const void* buffer, uint32_t size) { + using Fn = uint32_t(__fastcall*)(Stream*, void*, const void*, uint32_t); + return reinterpret_cast(stream->vtable[2])(stream, nullptr, buffer, size); +} +inline void Seek(Stream* stream, uint32_t position) { + using Fn = void(__fastcall*)(Stream*, void*, uint32_t); + reinterpret_cast(stream->vtable[3])(stream, nullptr, position); +} +inline uint32_t Tell(Stream* stream) { + using Fn = uint32_t(__fastcall*)(Stream*, void*); + return reinterpret_cast(stream->vtable[5])(stream, nullptr); +} + +// A cFile over a byte vector (reads from / writes at `position`). +struct MemoryStream { + void** vtable; + std::vector* data; + uint32_t position; +}; + +MemoryStream MakeMemoryStream(std::vector& data); + +inline Stream* AsStream(MemoryStream& memory) { + return reinterpret_cast(&memory); +} + +// Lower-case hex text of `size` bytes, and back (throws on a wrong length). +std::string Hex(const uint8_t* p, size_t size); +void Unhex(const std::string& s, uint8_t* out, size_t size); + +} // namespace game diff --git a/src/game/ui/tabs.h b/src/game/ui/tabs.h new file mode 100644 index 0000000..4e2551e --- /dev/null +++ b/src/game/ui/tabs.h @@ -0,0 +1,82 @@ +#pragma once +// cUI_TabSheet (0x80 bytes, vtable 0x895258): a control with tab headers +// (the inventory's "Rucksack" / "Kampfkünste" / "Combos"). Its cUI_TabControl +// at +0x78 (0x98 bytes, constructor 0x7278f0) holds the tabs: a vector of +// 0x18-byte entries at +0x78/+0x7c {text id, rect (+4 x, +8 y, +0xc w|h), +// header control (+0x10), page (+0x14)}, the active index at +0x84 (u16). +// Header controls: +0x24 x, +0x28 y, +0x2c w|h (relative to the sheet). +// Layout (0x729210) puts the headers in a row above the sheet; a click on a +// header reaches SetActive, which hides the old page and shows the new one +// (page vf24(show)). Selected / hover / normal look is the game's own +// (cUI_TabControl::Render 0x728090). + +#include +#include + +#include "game/ui/text.h" + +namespace game { + +constexpr size_t kTabSheetSize = 0x80; +constexpr size_t kTabSheetControl = 0x78; // cUI_TabControl* +constexpr size_t kTabControlTabs = 0x78; // entries begin; +0x7c end +constexpr size_t kTabEntrySize = 0x18; +constexpr size_t kTabEntryRect = 0x4; +constexpr size_t kTabEntryHeader = 0x10; +constexpr size_t kControlRect = 0x24; // a control's rect (x, y, w|h), relative to its parent +// The header art's height (left piece; static data filled when the UI loads). +constexpr uintptr_t kTabHeaderArtHeight = 0x009ed17c; + +// The game's operator new (0x8485e2, cdecl): controls the UI deletes itself +// must come from it. +inline void* GameNew(size_t size) { + return reinterpret_cast(0x008485e2)(size); +} + +// cUI_TabSheet::cUI_TabSheet (0x728ed0, ret 0x18): (name, rect by value, +// parent, 5); the inventory passes 5 as the last argument. +inline void* NewTabSheet(const char* name, UiRect rect, void* parent) { + void* sheet = GameNew(kTabSheetSize); + using ConstructFn = void*(__fastcall*)(void*, void*, const char*, UiRect, void*, uint32_t); + return sheet ? reinterpret_cast(0x00728ed0)(sheet, nullptr, name, rect, parent, 5) : nullptr; +} + +// cUI_TabSheet::AddTab (0x7290d0, ret 8): (text id, page control). +inline void AddTab(void* sheet, uint32_t textId, void* page) { + using AddFn = char(__fastcall*)(void*, void*, uint32_t, void*); + reinterpret_cast(0x007290d0)(sheet, nullptr, textId, page); +} + +// cUI_TabSheet::SetActive (0x7291d0, ret 4). +inline void SetActiveTab(void* sheet, uint16_t index) { + using SetFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x007291d0)(sheet, nullptr, index); +} + +inline uint8_t* TabControl(void* sheet) { + return *reinterpret_cast(static_cast(sheet) + kTabSheetControl); +} + +// The tab entries of a cUI_TabControl. +inline uint8_t* TabEntries(uint8_t* control) { + return *reinterpret_cast(control + kTabControlTabs); +} +inline size_t TabCount(uint8_t* control) { + uint8_t* begin = TabEntries(control); + uint8_t* end = *reinterpret_cast(control + kTabControlTabs + 4); + return begin ? static_cast(end - begin) / kTabEntrySize : 0; +} + +} // namespace game + +namespace game { + +// cUI_Window2::AddControl (0x727120, ret 4): the window renders the controls +// in its list (+0x78) and hands them its events (cUI_Window2::Render 0x727040, +// HandleEvent 0x726880). A control's parent alone doesn't do that. +inline void AddWindowControl(void* window, void* control) { + using AddFn = void(__fastcall*)(void*, void*, void*); + reinterpret_cast(0x00727120)(window, nullptr, control); +} + +} // namespace game diff --git a/src/game/ui/texts.cpp b/src/game/ui/texts.cpp new file mode 100644 index 0000000..6a8f25f --- /dev/null +++ b/src/game/ui/texts.cpp @@ -0,0 +1,111 @@ +#include "game/ui/texts.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "re/hook.h" + +namespace game { +namespace { + +// Our ids: above the game's (resource ids are small; bit 31 means "by index"). +constexpr uint32_t kFirstOwnText = 0x7fff0000; + +// The string GetText returns: a wide string as {begin, end (at the +// terminator), capacity end} (the game's own at cScriptResourceDlg + 8). +struct GameString { + const wchar_t* begin; + const wchar_t* end; + const wchar_t* capacity; +}; + +struct Capitalized { + std::wstring source; + std::wstring text; + GameString string; +}; + +std::mutex g_lock; +std::vector& Texts() { + static std::vector texts; + return texts; +} +std::unordered_set& CapitalizedIds() { + static std::unordered_set ids; + return ids; +} +// By id; nodes stay put, so the strings handed out stay valid. +std::unordered_map g_capitalized; + +std::wstring Capitalize(std::wstring text) { + bool wordStart = true; + for (wchar_t& c : text) { + if (std::iswalpha(c)) { + wchar_t buffer[2] = {c, 0}; + if (wordStart) { + CharUpperBuffW(buffer, 1); + } else { + CharLowerBuffW(buffer, 1); + } + c = buffer[0]; + wordStart = false; + } else { + wordStart = !(c == L'\'' || c == L'-'); + } + } + return text; +} + +} // namespace + +uint32_t RegisterText(const wchar_t* text) { + std::lock_guard guard(g_lock); + const size_t length = std::wcslen(text); + Texts().push_back({text, text + length, text + length}); + return kFirstOwnText + static_cast(Texts().size() - 1); +} + +void CapitalizeText(uint32_t id) { + std::lock_guard guard(g_lock); + CapitalizedIds().insert(id); +} + +// GetText (0x672f70, ret 4): (id) -> string; ours first. +const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id); +RE_HOOK(0x00672f70, TextManager_GetText); +const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) { + if (id >= kFirstOwnText && id < 0x80000000) { + std::lock_guard guard(g_lock); + const size_t index = id - kFirstOwnText; + if (index < Texts().size()) { + return &Texts()[index]; + } + } + const void* original = TextManager_GetText_original(self, edx, id); + std::lock_guard guard(g_lock); + if (!original || !CapitalizedIds().count(id)) { + return original; + } + const auto* string = static_cast(original); + if (!string->begin || string->end <= string->begin) { + return original; + } + Capitalized& entry = g_capitalized[id]; + if (entry.source.compare(0, std::wstring::npos, string->begin, string->end - string->begin) != 0 || + entry.text.empty()) { + entry.source.assign(string->begin, string->end); + entry.text = Capitalize(entry.source); + const wchar_t* text = entry.text.c_str(); + entry.string = {text, text + entry.text.size(), text + entry.text.size()}; + } + return &entry.string; +} + +} // namespace game diff --git a/src/game/ui/texts.h b/src/game/ui/texts.h new file mode 100644 index 0000000..70606ce --- /dev/null +++ b/src/game/ui/texts.h @@ -0,0 +1,18 @@ +#pragma once +// Game texts by id (TextManager 0x6728c0, GetText 0x672f70): texts of our own +// under ids the game does not use, for controls that take a text id (e.g. +// tab headers), and changes to the game's own texts. + +#include + +namespace game { + +// A new text id for `text` (kept for the rest of the run; `text` must stay +// valid, e.g. a literal). +uint32_t RegisterText(const wchar_t* text); + +// Shows the game's text `id` with each word capitalized ("RUCKSACK" -> +// "Rucksack"). Call at startup. +void CapitalizeText(uint32_t id); + +} // namespace game diff --git a/src/mods/chainlightning.cpp b/src/mods/chainlightning.cpp index ecf84ab..ccc34af 100644 --- a/src/mods/chainlightning.cpp +++ b/src/mods/chainlightning.cpp @@ -11,7 +11,7 @@ // // This mod keeps all of that and decides: // - hits per chain N = 3 + level / 5: the first bolt's [14]; -// - the next target: the nearest enemy within Blitzschlag's cast range (see +// - the next target: the nearest enemy within half Blitzschlag's cast range (see // below; the game's own jump search used about 316) that this // chain has not hit yet; else another enemy; else the current target again // (a lone enemy takes all N hits); @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -31,6 +32,7 @@ #include "game/engine/fx.h" #include "game/engine/objects.h" #include "game/engine/orders.h" +#include "re/hook.h" #include "re/log.h" #include "re/patch.h" @@ -47,13 +49,27 @@ constexpr uint32_t kFindJumpTarget = 0x006005d0; // cObjectManager::FindJumpTar // Blitzschlag's cast range: the art code (cCreature art switch 0x5222c0, // case 0x21) lays the ray from the caster towards the target in segments of // 30, at most 20, each on passable ground (0x636b60); only if the target is -// reached is the bolt created on it. A jump may go as far as a cast. One place, -// so it can become dynamic (levels, attributes) later. -constexpr double kRaySegmentLength = 30.0; +// reached is the bolt created on it. A jump may go as far as a cast. +// The segment count is bound by the creature's array of segment ids +// (+0x263, 20 entries), so the range grows by longer segments (request of +// 2026-10-17: range +50 %, 600 -> 900): the ray's step and the segment length +// are patched in the art code, where the game uses its shared 30.0 constant. +constexpr float kRaySegmentLengthOriginal = 30.0f; +constexpr float kRaySegmentLength = 45.0f; constexpr int kRaySegments = 20; double CastRange() { - return kRaySegmentLength * kRaySegments; + return static_cast(kRaySegmentLength) * kRaySegments; } +// A jump between enemies reaches half as far as the cast (request of +// 2026-10-17: 900 felt too far for jumps). +double JumpRange() { + return CastRange() / 2; +} +constexpr uint32_t kConstant30 = 0x0088ef18; // float 30.0, shared by 72 instructions +constexpr uint32_t kRayRemainingMinusSegment = 0x00526db6; // fsub [30.0] +constexpr uint32_t kRayLastSegmentCheck = 0x00526e0b; // fcomp [30.0]: remaining shorter than a segment? +constexpr uint32_t kRaySegmentLengthInt = 0x00526e23; // mov eax,30: a full segment's length +constexpr uint32_t kRayStep = 0x00526f3d; // push 30.0f: advance the ray point constexpr size_t kObjectOwnerPlayer = 0x39; // u8, copied from the caster like the game does constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed constexpr size_t kEventHitsLeft = 14; @@ -89,15 +105,23 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) { } } +// Hits per chain and chains at an art level. +uint32_t Hits(unsigned level) { + return 3 + level / 5; +} +unsigned Chains(unsigned level) { + return 1 + level / 10; +} + unsigned ArtLevel(void* creature, uint16_t art) { uint8_t* entry = game::FindCooldownEntry(static_cast(creature) + game::kCreatureArts, art); return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0; } -// The nearest living enemy of `caster` within the cast range of `from`, not +// The nearest living enemy of `caster` within the jump range of `from`, not // `current`; one not in `avoid` if there is any. uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector& avoid) { - return game::NearestHostile(caster, from, CastRange(), current, avoid.data(), avoid.size()); + return game::NearestHostile(caster, from, JumpRange(), current, avoid.data(), avoid.size()); } void Forget() { @@ -160,8 +184,8 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) { } Forget(); const unsigned level = ArtLevel(caster, g_heroArt); - const uint32_t hits = 3 + level / 5; - const unsigned chains = 1 + level / 10; + const uint32_t hits = Hits(level); + const unsigned chains = Chains(level); event[kEventHitsLeft] = hits; auto chain = std::make_shared(); chain->hit.push_back(target); @@ -200,6 +224,18 @@ __declspec(naked) void NextTargetThunk() { void Startup() { const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast(&NextTargetThunk)); re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)"); + static const float segmentLength = kRaySegmentLength; + const auto bits = [](float value) { + uint32_t result; + std::memcpy(&result, &value, sizeof(result)); + return result; + }; + const bool range = re::RedirectOperand(kRayRemainingMinusSegment, kConstant30, &segmentLength) && + re::RedirectOperand(kRayLastSegmentCheck, kConstant30, &segmentLength) && + re::PatchInstructionDword(kRaySegmentLengthInt, static_cast(kRaySegmentLengthOriginal), + static_cast(kRaySegmentLength)) && + re::PatchPushImm32(kRayStep, bits(kRaySegmentLengthOriginal), bits(kRaySegmentLength)); + re::Log("chainlightning: cast range %.0f%s", CastRange(), range ? "" : " (patch FAILED)"); } RE_STARTUP(ChainLightningStartup, Startup); @@ -237,3 +273,86 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui } } // namespace mods::chainlightning + +// The tooltip: the game's "Anzahl: X" line (its hit count) becomes the mod's +// two numbers, hits per chain and chains, in both the current level's section +// and the next level's (skill book). cCreatureArts::ArtTooltip (0x589270, +// thiscall on creature +0x3a8, (art, text, flag), ret 0xc) builds the text +// into a wide string {begin, end, capacity}; sections start with the level +// line, values are "Name: value" lines separated by '\n' (measured for art +// 0x21 at level 5: "...|Aktuelle Stufe: 5|Regeneration: 3.5s|Schaden: ...| +// Anzahl: 7", with flag 1 also "Nächste erlernte Stufe: 6" and its values). +namespace mods::chainlightning { +namespace { + +constexpr uint16_t kArtBlitzschlag = 0x21; // "Kettenblitz" in the tooltip +constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:"; +constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:"; +constexpr const wchar_t* kCountLine = L"Anzahl:"; + +// The game's wide string append (0x592ef0, thiscall (first, last, unused), +// ret 0xc); grows the buffer with the game's allocator. +void AppendText(void* text, const wchar_t* first, const wchar_t* last) { + using Fn = void*(__fastcall*)(void*, void*, const wchar_t*, const wchar_t*, uint32_t); + reinterpret_cast(0x00592ef0)(text, nullptr, first, last, 0); +} + +void SetText(void* text, const std::wstring& value) { + auto** string = static_cast(text); + if (string[0]) { + string[1] = string[0]; + *string[0] = L'\0'; + } + AppendText(text, value.data(), value.data() + value.size()); +} + +std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) { + std::wstring out; + unsigned level = currentLevel; + size_t start = 0; + while (start < text.size()) { + size_t end = text.find(L'\n', start); + end = end == std::wstring::npos ? text.size() : end + 1; + const std::wstring line = text.substr(start, end - start); + start = end; + if (line.find(kCurrentLevelLine) != std::wstring::npos) { + level = currentLevel; + } else if (line.find(kNextLevelLine) != std::wstring::npos) { + level = currentLevel + 1; + } + const size_t count = line.find(kCountLine); + if (count == std::wstring::npos) { + out += line; + continue; + } + const std::wstring prefix = line.substr(0, count); // colour codes + const std::wstring newline = line.back() == L'\n' ? L"\n" : L""; + out += prefix + L"Treffer je Kette: " + std::to_wstring(Hits(level)) + L"\n"; + out += prefix + L"Ketten: " + std::to_wstring(Chains(level)) + L"\n"; + out += prefix + L"Reichweite: " + std::to_wstring(static_cast(CastRange())) + L"\n"; + out += prefix + L"Sprungweite: " + std::to_wstring(static_cast(JumpRange())) + newline; + } + return out; +} + +void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag); +RE_HOOK(0x00589270, Arts_Tooltip); +void __fastcall Arts_Tooltip(void* self, void* edx, uint32_t art, void* text, uint32_t flag) { + Arts_Tooltip_original(self, edx, art, text, flag); + if (!text || static_cast(art) != kArtBlitzschlag) { + return; + } + auto* const* string = static_cast(text); + if (!string[0] || string[1] <= string[0]) { + return; + } + void* creature = static_cast(self) - game::kCreatureArts; + const std::wstring before(string[0], string[1]); + const std::wstring after = Rewrite(before, ArtLevel(creature, kArtBlitzschlag)); + if (after != before) { + SetText(text, after); + } +} + +} // namespace +} // namespace mods::chainlightning diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index dfa2f2a..a5ef398 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -489,6 +489,16 @@ int VisibleDialogTooltipsImpl(void** out, int max) { return count; } +// A tooltip is destroyed: its entry goes, so its memory is never taken for a +// tooltip again (Esc closed a "visible dialog" that was a freed one: crash). +void ForgetTooltip(void* tooltip) { + for (TooltipState& t : g_tooltips) { + if (t.tooltip == tooltip) { + t = {}; + } + } +} + int KnownTooltipOffset(void* tooltip) { for (const TooltipState& t : g_tooltips) { if (t.tooltip == tooltip) { @@ -598,8 +608,7 @@ HUD_EVENT(0x006d1080, UiMaster_HandleEvent, Anchor::Center) HUD_RENDER(0x006d0f00, UiMaster_Render, Anchor::Center) HUD_EVENT(0x006d2450, UiMasterCombo_HandleEvent, Anchor::Center) HUD_RENDER(0x006d2b70, UiMasterCombo_Render, Anchor::Center) -HUD_EVENT(0x006d54e0, UiMasterLotto_HandleEvent, Anchor::Center) -HUD_RENDER(0x006d4720, UiMasterLotto_Render, Anchor::Center) +// cUI_MasterLotto (rune master): event and render in mods/runetrade.cpp. HUD_EVENT(0x006d5e20, UiMasterPortal_HandleEvent, Anchor::Center) HUD_RENDER(0x006d5e00, UiMasterPortal_Render, Anchor::Center) HUD_EVENT(0x006dab10, UiMerchandise_HandleEvent, Anchor::Center) @@ -663,6 +672,14 @@ int __fastcall UiTooltip_Render(void* self, void* edx, void* device) { return UiTooltip_Render_original(self, edx, device); } +// cUI_Tooltip::~cUI_Tooltip (0x6e6560, plain ret; from the deleting destructor vf00). +void __fastcall UiTooltip_Destruct(void* self, void* edx); +RE_HOOK(0x006e6560, UiTooltip_Destruct); +void __fastcall UiTooltip_Destruct(void* self, void* edx) { + mods::hud::ForgetTooltip(self); + UiTooltip_Destruct_original(self, edx); +} + } // namespace game namespace mods::hud { diff --git a/src/mods/input.cpp b/src/mods/input.cpp index cedab60..2ba1b04 100644 --- a/src/mods/input.cpp +++ b/src/mods/input.cpp @@ -20,17 +20,20 @@ // SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art) // WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set // ActionSlot3=G ; what W did (W is movement) -// PickUp=V ; what A did (pick up items nearby) +// PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up) // SaveGame=K ; what S did (save game window) #include "mods/input.h" +#include #include #include #include #include +#include "game/engine/events.h" #include "game/engine/objects.h" +#include "game/engine/orders.h" #include "mods/skillbar.h" #include "mods/skillkeys.h" #include "mods/wasd.h" @@ -43,7 +46,7 @@ namespace mods::input { namespace { // --- sacred.exe --------------------------------------------------------------- -constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*: focused control at +0x148 +constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager* constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it constexpr uint32_t kEventKeyVtable = 0x00895568; @@ -75,7 +78,7 @@ void PostGameKey(char key, bool pressed) { // --- bindings ------------------------------------------------------------------ -enum class Kind { SkillSlot, WeaponSet, GameKey }; +enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp }; struct Binding { const char* name; @@ -102,7 +105,7 @@ Binding g_bindings[] = { {"WeaponSet6", "F6", Kind::WeaponSet, 5, 0}, {"WeaponSet7", "F7", Kind::WeaponSet, 6, 0}, {"ActionSlot3", "G", Kind::GameKey, 0, 'W'}, - {"PickUp", "V", Kind::GameKey, 0, 'A'}, + {"PickUp", "Q", Kind::PickUp, 0, 0}, {"SaveGame", "K", Kind::GameKey, 0, 'S'}, }; @@ -147,6 +150,37 @@ bool Reserved(int vk) { return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(vk); } +// Pick up items nearby: the order the engine's A hotkey sends (creature order +// 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc), +// given to the controlled creature on the next engine tick. (Posting the A +// key event did not reach it: no pickup was logged.) +constexpr uint32_t kOrderPickUp = 0x113; +std::atomic g_pickUp{false}; +// The collect mode (0x4ec3a0, substate 0 without a target) looks for an item +// in square rings of map cells around the hero, nearest ring first, while the +// ring's low offset is above -20 (cmp edi,-20 at 0x4ec8c6). Twice as far. +constexpr uint32_t kCollectRingLimit = 0x004ec8c6; +constexpr uint8_t kCollectRingsOriginal = 0xec; // -20 +constexpr uint8_t kCollectRings = 0xd8; // -40 + + +void OnTick(uint8_t* engine) { + if (!g_pickUp.exchange(false)) { + return; + } + if (void* hero = game::ControlledCreature(engine)) { + game::CreatureOrder order; + order.command = kOrderPickUp; + game::SendCreatureOrder(engine, hero, order); + } +} + +struct Registration { + Registration() { + game::OnEngineTick(&OnTick); + } +} g_registration; + void Press(const Binding& binding, int vk) { const auto slot = static_cast(binding.index); switch (binding.kind) { @@ -159,6 +193,9 @@ void Press(const Binding& binding, int vk) { case Kind::GameKey: PostGameKey(binding.gameKey, true); break; + case Kind::PickUp: + g_pickUp = true; + break; } } @@ -168,6 +205,7 @@ void Release(const Binding& binding, int vk) { mods::skillkeys::KeyReleased(vk); break; case Kind::WeaponSet: + case Kind::PickUp: break; case Kind::GameKey: PostGameKey(binding.gameKey, false); @@ -184,23 +222,45 @@ bool g_swallowChar = false; void Startup() { LoadBindings(); + if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) { + re::Log("input: pickup range patch FAILED"); + } re::Log("input: key bindings active"); } RE_STARTUP(InputStartup, Startup); +// The text field that has the keyboard, as the game decides it: the UI +// manager hands key events to its dialog (+0x144) while that is shown +// (+0x10 bit 0; cUI_Manager::ReceiveEvent 0x7573ba), and the dialog's focused +// control (+0x148) is an edit control without the read-only bit (+0x10 bit +// 16). The focus pointer alone stays set after the dialog closed (measured: +// the save window's 'DEFAULT_EDIT' after closing it), which switched the +// bindings off. +constexpr size_t kUiDialog = 0x144; +constexpr size_t kUiFocus = 0x148; +constexpr size_t kControlFlags = 0x10; +constexpr uint32_t kControlVisible = 1; +constexpr uint32_t kEditReadOnly = 1u << 16; + +uint8_t* FocusedTextField() { + auto* ui = *reinterpret_cast(kUiManager); + auto* dialog = ui ? *reinterpret_cast(ui + kUiDialog) : nullptr; + if (!dialog || (*reinterpret_cast(dialog + kControlFlags) & kControlVisible) == 0) { + return nullptr; + } + void* focus = *reinterpret_cast(ui + kUiFocus); + auto* edit = static_cast(focus ? game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl) + : nullptr); + if (!edit || (*reinterpret_cast(edit + kControlFlags) & kEditReadOnly) != 0) { + return nullptr; + } + return edit; +} + } // namespace bool TypingIntoTextField() { - auto* ui = *reinterpret_cast(kUiManager); - if (!ui) { - return false; - } - void* focus = *reinterpret_cast(ui + 0x148); - if (!focus) { - return false; - } - auto* edit = static_cast(game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl)); - return edit && ((*reinterpret_cast(edit + 0x10) >> 16) & 1) == 0; + return FocusedTextField() != nullptr; } bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { @@ -210,7 +270,7 @@ bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { const bool repeat = (lParam & (1 << 30)) != 0; if (!repeat) { g_taken[vk] = false; - if (TypingIntoTextField()) { + if (FocusedTextField()) { return false; } const Binding* binding = BindingFor(vk); diff --git a/src/mods/inventory_save.cpp b/src/mods/inventory_save.cpp index 53bbaa9..3f65652 100644 --- a/src/mods/inventory_save.cpp +++ b/src/mods/inventory_save.cpp @@ -26,7 +26,10 @@ #include "game/engine/events.h" #include "game/engine/items.h" -#include "mods/potionstack.h" +#include "game/engine/stream.h" +#include "mods/inventory_save.h" +#include "mods/itemstack.h" +#include "mods/sharedchest.h" #include "mods/skillslots.h" #include "re/hook.h" #include "re/log.h" @@ -36,81 +39,64 @@ namespace { using nlohmann::json; using namespace mods::skillslots; +using game::Hex; +using game::MakeMemoryStream; +using game::MemoryStream; +using game::Read; +using game::Seek; +using game::Stream; +using game::Tell; +using game::Unhex; +using game::Write; + +struct HeroData { + const char* key; + HeroDataWriteFn write; + HeroDataReadFn read; +}; +std::vector& HeroDataList() { + static std::vector list; + return list; +} + +// The hero index of a hero inventory, or -1 (chest, traders, other uses). +int HeroIndexOf(const void* inventory) { + for (unsigned i = 0; i < game::kChestInventory; ++i) { + if (mods::skillslots::Inventory(i) == inventory) { + return static_cast(i); + } + } + return -1; +} + +void WriteHeroData(json& j, const void* inventory) { + const int hero = HeroIndexOf(inventory); + if (hero < 0) { + return; + } + for (const HeroData& data : HeroDataList()) { + json value = data.write(static_cast(hero)); + if (!value.is_null()) { + j["hero"][data.key] = std::move(value); + } + } +} + +void ReadHeroData(const json& j, const void* inventory) { + const int hero = HeroIndexOf(inventory); + if (hero < 0) { + return; + } + const json empty = json::object(); + const json& all = j.contains("hero") ? j.at("hero") : empty; + for (const HeroData& data : HeroDataList()) { + data.read(static_cast(hero), all.contains(data.key) ? all.at(data.key) : json()); + } +} constexpr char kMagic[4] = {'S', 'R', 'I', 'J'}; constexpr int kVersion = 1; -// --- streams ------------------------------------------------------------------- - -// The game's file interface (cFile): vf04 read, vf08 write, vf0c seek, vf10 -// size, vf14 tell; all __thiscall. -struct Stream { - void** vtable; -}; - -uint32_t Read(Stream* stream, void* buffer, uint32_t size) { - using Fn = uint32_t(__fastcall*)(Stream*, void*, void*, uint32_t); - return reinterpret_cast(stream->vtable[1])(stream, nullptr, buffer, size); -} -uint32_t Write(Stream* stream, const void* buffer, uint32_t size) { - using Fn = uint32_t(__fastcall*)(Stream*, void*, const void*, uint32_t); - return reinterpret_cast(stream->vtable[2])(stream, nullptr, buffer, size); -} -void Seek(Stream* stream, uint32_t position) { - using Fn = void(__fastcall*)(Stream*, void*, uint32_t); - reinterpret_cast(stream->vtable[3])(stream, nullptr, position); -} -uint32_t Tell(Stream* stream) { - using Fn = uint32_t(__fastcall*)(Stream*, void*); - return reinterpret_cast(stream->vtable[5])(stream, nullptr); -} - -// A cFile over a byte vector. -struct MemoryStream { - void** vtable; - std::vector* data; - uint32_t position; -}; - -void* __fastcall MemDestroy(MemoryStream* self, void*, int) { - return self; -} -uint32_t __fastcall MemRead(MemoryStream* self, void*, void* buffer, uint32_t size) { - const uint32_t available = static_cast(self->data->size()) - self->position; - const uint32_t n = size < available ? size : available; - std::memcpy(buffer, self->data->data() + self->position, n); - self->position += n; - return n; -} -uint32_t __fastcall MemWrite(MemoryStream* self, void*, const void* buffer, uint32_t size) { - if (self->data->size() < self->position + size) { - self->data->resize(self->position + size); - } - std::memcpy(self->data->data() + self->position, buffer, size); - self->position += size; - return size; -} -void __fastcall MemSeek(MemoryStream* self, void*, uint32_t position) { - self->position = position; -} -uint32_t __fastcall MemSize(MemoryStream* self, void*) { - return static_cast(self->data->size()); -} -uint32_t __fastcall MemTell(MemoryStream* self, void*) { - return self->position; -} - -void* g_memoryVtable[8] = { - reinterpret_cast(&MemDestroy), reinterpret_cast(&MemRead), - reinterpret_cast(&MemWrite), reinterpret_cast(&MemSeek), - reinterpret_cast(&MemSize), reinterpret_cast(&MemTell), - reinterpret_cast(0x0065afb0), reinterpret_cast(0x0065af80), // cFile helpers -}; - -MemoryStream MakeMemoryStream(std::vector& data) { - return MemoryStream{g_memoryVtable, &data, 0}; -} - // --- block <-> JSON ------------------------------------------------------------ // The block Serialize writes, in order. @@ -151,28 +137,6 @@ public: std::vector data; }; -std::string Hex(const uint8_t* p, size_t size) { - static const char digits[] = "0123456789abcdef"; - std::string s(size * 2, '0'); - for (size_t i = 0; i < size; ++i) { - s[2 * i] = digits[p[i] >> 4]; - s[2 * i + 1] = digits[p[i] & 15]; - } - return s; -} - -void Unhex(const std::string& s, uint8_t* out, size_t size) { - if (s.size() != size * 2) { - throw std::runtime_error("hex field has the wrong length"); - } - auto nibble = [](char c) -> uint8_t { - return static_cast(c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10); - }; - for (size_t i = 0; i < size; ++i) { - out[i] = static_cast(nibble(s[2 * i]) << 4 | nibble(s[2 * i + 1])); - } -} - bool AllZero(const uint8_t* p, size_t size) { for (size_t i = 0; i < size; ++i) { if (p[i]) { @@ -245,8 +209,8 @@ json ToJson(const std::vector& block, const uint8_t* shadow) { const uint8_t* cell = cells + i * kGridCellSize; if (!AllZero(cell, kGridCellSize)) { json entry = {{"cell", i}, {"item", U32At(cell)}, {"data", Hex(cell + 4, kGridCellSize - 4)}}; - if (const unsigned count = mods::potionstack::StackCount(U32At(cell)); count > 1) { - entry["count"] = count; // a potion stack (src/mods/potionstack.cpp) + if (const unsigned count = mods::itemstack::StackCount(U32At(cell)); count > 1) { + entry["count"] = count; // a potion stack (src/mods/itemstack.cpp) } grid.push_back(entry); } @@ -357,6 +321,10 @@ std::vector ToBlock(const json& j) { } // namespace // Defined in namespace game below. +void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn read) { + HeroDataList().push_back({key, write, read}); +} + } // namespace mods::inventory_save namespace game { @@ -380,13 +348,15 @@ int __fastcall InvManager_Serialize(void* self, void* edx, void* stream, int wri ++g_heroFileDepth; g_readJson = g_readVanilla = 0; if (!static_cast(write)) { - mods::potionstack::ResetStacks(); // the ids of the previous game are gone + mods::itemstack::ResetStacks(); // the ids of the previous game are gone } const int result = InvManager_Serialize_original(self, edx, stream, write, x); --g_heroFileDepth; if (!static_cast(write)) { re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla); NotifySaveGameLoaded(); + } else { + mods::sharedchest::Save(); // in-game saves write the save game, not saveHero } return result; } @@ -398,6 +368,9 @@ uint32_t __cdecl ItemDataMgr_SaveHero(uint32_t a, uint32_t b, uint32_t c, uint32 ++g_heroFileDepth; const uint32_t result = ItemDataMgr_SaveHero_original(a, b, c, d, e, f); --g_heroFileDepth; + if (result) { + mods::sharedchest::Save(); // with every saved hero, the shared chest too + } return result; } @@ -423,7 +396,9 @@ int __fastcall Inv_Serialize(void* self, void* edx, void* streamArg, int write) std::vector block; MemoryStream memory = MakeMemoryStream(block); Inv_Serialize_original(self, edx, &memory, 1); - const std::string text = ToJson(block, mods::skillslots::ShadowFor(self, false)).dump(1); + json j = ToJson(block, mods::skillslots::ShadowFor(self, false)); + WriteHeroData(j, self); + const std::string text = j.dump(1); const auto length = static_cast(text.size()); Write(stream, kMagic, 4); Write(stream, &length, 4); @@ -449,10 +424,11 @@ int __fastcall Inv_Serialize(void* self, void* edx, void* streamArg, int write) MemoryStream memory = MakeMemoryStream(block); Inv_Serialize_original(self, edx, &memory, 0); // also clears the shadow (Clear) ++g_readJson; + ReadHeroData(j, self); for (const json& cell : j.at("grid")) { if (cell.contains("count")) { const size_t i = cell.at("cell"); - mods::potionstack::SetStackCount(game::GridItem(static_cast(self), i), cell.at("count")); + mods::itemstack::SetStackCount(game::GridItem(static_cast(self), i), cell.at("count")); } } for (unsigned s = kGameSlots; s < kSlots; ++s) { diff --git a/src/mods/inventory_save.h b/src/mods/inventory_save.h new file mode 100644 index 0000000..2fc19c3 --- /dev/null +++ b/src/mods/inventory_save.h @@ -0,0 +1,16 @@ +#pragma once +// Per-hero data of other mods, saved with the hero's inventory JSON (save +// games and hero files, src/mods/inventory_save.cpp) under "hero": {key: ...}. + +#include + +namespace mods::inventory_save { + +// `hero`: the hero's inventory index (= its creature id, below the chest's 0x11). +using HeroDataWriteFn = nlohmann::json (*)(unsigned hero); // null: nothing to save +using HeroDataReadFn = void (*)(unsigned hero, const nlohmann::json& data); + +// Call at startup (static initialisation). +void RegisterHeroData(const char* key, HeroDataWriteFn write, HeroDataReadFn read); + +} // namespace mods::inventory_save diff --git a/src/mods/potionstack.cpp b/src/mods/itemstack.cpp similarity index 78% rename from src/mods/potionstack.cpp rename to src/mods/itemstack.cpp index 5ef151e..b6378cf 100644 --- a/src/mods/potionstack.cpp +++ b/src/mods/itemstack.cpp @@ -1,33 +1,36 @@ -// Potion stacks (request 2026-10-17, docs/potion-stacks.md). +// Item stacks: potions and combat-art runes (requests 2026-10-17, +// docs/item-stacks.md). // -// Potions of the same item type share one inventory cell, up to kMaxStack. A -// stack is the one potion object in the cell plus a count kept here (by item -// id, with the item type to tell a reused id apart) and saved with the JSON -// inventories (src/mods/inventory_save.cpp). +// Items of the same type that the game counts as consumable (potions, runes; +// ItemIsConsumable 0x43f5f0) share one inventory cell, up to kMaxStack. Such +// items differ only in their object state (measured for runes), so the type +// alone decides. A stack is the one object in the cell plus a count kept here +// (by item id, with the item type to tell a reused id apart) and saved with +// the JSON inventories (src/mods/inventory_save.cpp). // // Stacks exist in the players' inventories and the chest (inventory 0x11); // trader stock waits for stack prices. // -// - Entry: a potion placed into a grid (picked up, bought, dropped from the +// - Entry: an item placed into a grid (picked up, bought, dropped from the // cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other // stacks of its type that have room, on the next engine tick (the caller // still uses the object while placing it). An emptied object is removed and // destroyed. // - Sort button (cInventoryEntry::Sort 0x5db670, the inventory window's -// button): all potions of a type are poured together first. -// - Drinking (cCreature::UseItem 0x54a8f0, measured): the potion's event -// handler (cPotion3D::vf18) applies the effect and marks it used up; UseItem -// then removes it from the grid (RemoveItem 0x5da5f0) and destroys it -// (cObjectManager::DestroyObject 0x5fbe10), and cEngine::UseActionSlot -// removes it once more. For a stack of more than one, the first removal -// takes one potion off the count instead, and the rest of that drink leaves -// the object alone. +// button): all items of a type are poured together first. +// - Use (cCreature::UseItem 0x54a8f0, measured for potions; runes, class 0x1c, +// take the same path): the item's event handler (vf18) applies the effect +// and marks it used up; UseItem then removes it from the grid (RemoveItem +// 0x5da5f0) and destroys it (cObjectManager::DestroyObject 0x5fbe10), and +// cEngine::UseActionSlot removes it once more. For a stack of more than +// one, the first removal takes one off the count instead, and the rest of +// that use leaves the object alone. // - Load: cInventoryEntry::ReplaceItemId (0x5d80e0, hooked in skillslots.cpp; // hero files map saved ids to the new objects) moves the count to the new id. // - Drawing: the count on each stack's cell, after cInventoryEntry::DrawItems // (0x5dc3f0; cell rects from CellRect 0x5dd530). -#include "mods/potionstack.h" +#include "mods/itemstack.h" #include @@ -42,7 +45,7 @@ #include "game/ui/text.h" #include "re/hook.h" -namespace mods::potionstack { +namespace mods::itemstack { namespace { constexpr unsigned kMaxStack = 20; @@ -62,7 +65,7 @@ struct Stack { std::mutex g_lock; std::unordered_map g_stacks; -// Potions placed into a grid, poured on the next tick. +// Stackable items placed into a grid, poured on the next tick. struct Entry { uint8_t* inventory; uint32_t id; @@ -70,7 +73,7 @@ struct Entry { std::vector g_entered; // Inside cInventoryEntry::Sort (its re-placements are no entries). thread_local bool g_sorting = false; -// The stack a drink just took a potion from (per thread: drinks come from the +// The stack a use just took one from (per thread: uses come from the // engine and the UI thread). thread_local uint32_t g_drunk = 0; thread_local DWORD g_drunkAt = 0; @@ -80,23 +83,31 @@ uint32_t TypeOf(uint32_t id) { return item ? game::ItemType(item) : 0; } -// Potions stack in the players' inventories and the chest; trader stock not +// Stacks exist in the players' inventories and the chest; trader stock not // yet (buying from a stack would sell the whole stack for one price). bool Stacks(const void* inventory) { return inventory && game::IsPlayerInventory(static_cast(inventory)); } +bool Stackable(uint32_t type) { + return type && game::ItemIsConsumable(type); +} + +bool StackableItem(uint32_t id) { + return Stackable(TypeOf(id)); +} + bool InGrid(const uint8_t* inventory, uint32_t id) { bool found = false; game::ForEachGridItem(inventory, [&](uint32_t item) { found = found || item == id; }); return found; } -// Moves potions of `id` into the other stacks of its type in `inventory` +// Moves the items of `id` into the other stacks of its type in `inventory` // that have room (in grid order); an emptied `id` is removed and destroyed. void Pour(uint8_t* inventory, uint32_t id) { const uint32_t type = TypeOf(id); - if (!type || !game::ItemPotionKind(type)) { + if (!Stackable(type)) { return; } const unsigned had = StackCount(id); @@ -125,17 +136,17 @@ void Pour(uint8_t* inventory, uint32_t id) { game::DestroyObject(id); } -// Pours the potions from the back of the grid into those before them: full +// Pours the stacks from the back of the grid into those before them: full // stacks first. void StackAll(uint8_t* inventory) { - std::vector potions; + std::vector items; game::ForEachGridItem(inventory, [&](uint32_t id) { - if (game::PotionKindOf(id)) { - potions.push_back(id); + if (StackableItem(id)) { + items.push_back(id); } }); - for (size_t i = potions.size(); i-- > 0;) { - Pour(inventory, potions[i]); + for (size_t i = items.size(); i-- > 0;) { + Pour(inventory, items[i]); } } @@ -229,24 +240,36 @@ void OnItemIdReplaced(uint32_t oldId, uint32_t newId) { } } +uint32_t StackWithRoom(const uint8_t* inventory, uint32_t type) { + uint32_t found = 0; + if (inventory && Stacks(inventory) && Stackable(type)) { + game::ForEachGridItem(inventory, [&](uint32_t id) { + if (!found && TypeOf(id) == type && StackCount(id) < kMaxStack) { + found = id; + } + }); + } + return found; +} + void ResetStacks() { std::lock_guard guard(g_lock); g_stacks.clear(); g_entered.clear(); } -} // namespace mods::potionstack +} // namespace mods::itemstack namespace game { -using namespace mods::potionstack; +using namespace mods::itemstack; // cInventoryEntry::PlaceItem (0x5d9ec0, ret 0x14): (cell, id, w, h, notify). int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify); RE_HOOK(0x005d9ec0, Inv_PlaceItem); int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify) { const int previous = Inv_PlaceItem_original(self, edx, cell, id, w, h, notify); - if (!g_sorting && PotionKindOf(id) && Stacks(self)) { + if (!g_sorting && StackableItem(id) && Stacks(self)) { std::lock_guard guard(g_lock); g_entered.push_back({static_cast(self), id}); } @@ -264,7 +287,7 @@ int __fastcall Inv_RemoveItem(void* self, void* edx, uint32_t id, uint32_t notif auto* flags = item ? reinterpret_cast(item + kObjectFlags) : nullptr; const unsigned count = flags && (*flags & kObjectUsedUp) ? StackCount(id) : 1; if (count > 1) { - // A drink from a stack: one potion less, the object stays. + // A use from a stack: one less, the object stays. *flags &= ~kObjectUsedUp; SetStackCount(id, count - 1); g_drunk = id; @@ -293,7 +316,7 @@ char __fastcall Creature_UseItem(void* self, void* edx, uint32_t id) { return Creature_UseItem_original(self, edx, id); } -// cInventoryEntry::Sort (0x5db670, ret 4): stack the potions first. +// cInventoryEntry::Sort (0x5db670, ret 4): stack first. void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode); RE_HOOK(0x005db670, Inv_Sort); void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) { diff --git a/src/mods/potionstack.h b/src/mods/itemstack.h similarity index 59% rename from src/mods/potionstack.h rename to src/mods/itemstack.h index 177e138..0ad5a7c 100644 --- a/src/mods/potionstack.h +++ b/src/mods/itemstack.h @@ -2,9 +2,9 @@ #include -namespace mods::potionstack { +namespace mods::itemstack { -// Potions of one type in the stack held by item `id` (1 for any other item). +// Items of one type in the stack held by item `id` (1 for an item without one). unsigned StackCount(uint32_t id); // Sets the stack size of item `id` (1 or less: no stack). Used when loading. @@ -13,7 +13,10 @@ void SetStackCount(uint32_t id, unsigned count); // cInventoryEntry::ReplaceItemId: the stack follows its item to the new id. void OnItemIdReplaced(uint32_t oldId, uint32_t newId); +// An item of `type` in `inventory` whose stack has room for one more, or 0. +uint32_t StackWithRoom(const uint8_t* inventory, uint32_t type); + // Forgets all stacks (a save game is about to be loaded). void ResetStacks(); -} // namespace mods::potionstack +} // namespace mods::itemstack diff --git a/src/mods/potioncounts.cpp b/src/mods/potioncounts.cpp index 2632faf..ce6de87 100644 --- a/src/mods/potioncounts.cpp +++ b/src/mods/potioncounts.cpp @@ -8,7 +8,7 @@ // cInventoryEntry::GetPotionRef(k) finds a potion of kind k, else the empty // control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks // ItemPotionKind for each item's kind; the count here does the same and adds -// up the potion stacks (potionstack.cpp). +// up the potion stacks (itemstack.cpp). #include #include @@ -16,7 +16,7 @@ #include "game/engine/items.h" #include "game/engine/objects.h" #include "game/ui/text.h" -#include "mods/potionstack.h" +#include "mods/itemstack.h" #include "mods/skillslots.h" #include "re/hook.h" @@ -42,7 +42,7 @@ void RenderCounts(uint8_t* taskbar, void* renderer) { game::ForEachGridItem(inventory, [&](uint32_t id) { const int kind = game::PotionKindOf(id); if (kind >= 1 && kind <= game::kPotionKinds) { - counts[kind] += potionstack::StackCount(id); + counts[kind] += itemstack::StackCount(id); } }); const int lineHeight = game::FontLineHeight(kCountFont); diff --git a/src/mods/runetrade.cpp b/src/mods/runetrade.cpp new file mode 100644 index 0000000..26be10d --- /dev/null +++ b/src/mods/runetrade.cpp @@ -0,0 +1,498 @@ +// The rune master (cUI_MasterLotto), requests 2026-10-17: +// +// - Rune stacks: a rune stack dropped into one of the four slots gives up one +// rune; the rest stays on the cursor (or goes back to the backpack when the +// slot's old rune comes onto the cursor in exchange). +// - Buying: the rune to buy can always be chosen (the arrows), with 1 to 4 +// runes given; the price falls with the runes given (kPrices: 1,000 for 4, +// each missing rune x10). Buy works with at least one rune and enough gold. +// The last rune bought is remembered per hero (saved with the hero, +// src/mods/inventory_save.cpp) and chosen again when the window opens. +// +// The window, read (docs/item-stacks.md has the slot details): +// - HandleEvent (0x6d54e0): a left press over slot i (control at +0x154 + +// i * 0x114, hit test vf1c(x, y)) puts the slot's old item (descriptor at +// +0x24c + i * 0x114) on the cursor afterwards, and if the cursor carries a +// rune (descriptor kind 1, item class 0x1c) copies the cursor's descriptor +// into the slot and clears the cursor. The descriptor is the cursor's +// carried item (cursor +0x68): {u16 kind, u16 class, u32 id, ...}, 0x14 +// bytes. A press on the buy button (+0x7c8) with no bought rune waiting +// (+0x798) and gold >= price[runes] pays and buys (0x6d42a0). +// - The price table at 0xad2aac (u32 by rune count, the game fills 2..4 from +// its settings at 0x5efc7b) is read by the render, the button state and the +// pay helper 0x6d3ce0; buying needed 2 runes (cmp dx, 2 at 0x6d3e20, +// 0x6d4544, 0x6d4a13). +// - 0x6d42a0 (buy): 2 runes: a random rune (0x6d4180(0)), 3: random of the +// hero's class, 4: the chosen one (+0x7b4); the slot runes are destroyed and +// the new rune is created into +0x798 (type +0x79c), shown at +0x69c until +// taken. +// - 0x6d3d50 (after slot changes): with 4 runes shows the arrows (+0x5a4 next, +// +0x620 previous; doLottoSelect 0x6d3fd0(hero type, 1 / 0, 2 = keep)), +// otherwise hides them and clears the choice +0x7b4. + +#include +#include + +#include "game/engine/events.h" +#include "game/engine/items.h" +#include "game/engine/objects.h" +#include "game/engine/orders.h" +#include "game/ui/events.h" +#include "game/ui/tabs.h" +#include "game/ui/text.h" +#include "mods/hud.h" +#include "mods/inventory_save.h" +#include "mods/itemstack.h" +#include "mods/skillslots.h" +#include "re/hook.h" +#include "re/log.h" +#include "re/patch.h" + +namespace mods::runetrade { +namespace { + +// cUI_MasterLotto +constexpr int kSlots = 4; +constexpr size_t kSlotControls = 0x154; +constexpr size_t kSlotStride = 0x114; +constexpr size_t kSlotItems = 0x24c; // descriptors: kind (u16) 0: empty; the rune's id at +4 +constexpr size_t kNextArrow = 0x5a4; +constexpr size_t kPreviousArrow = 0x620; +constexpr size_t kChosenRune = 0x7b4; // item type +constexpr size_t kBuyButton = 0x7c8; +constexpr size_t kChosenControl = 0x69c; // shows the chosen rune's icon +constexpr size_t kChosenObject = 0x7b0; // an object of the chosen rune (doLottoSelect), for its name +// The buy button's text colour (+0x840 = button +0x78) and its colour set +// (button +0x54..+0x60, 0x6d5d90), enabled / disabled, as the render picks them. +constexpr size_t kBuyTextColour = 0x840; +constexpr uint32_t kDisabledTextColour = 0x009f028c; +constexpr uint32_t kDisabledColours = 0x009f0290; +constexpr size_t kControlHitTest = 0x1c / 4; +constexpr size_t kControlFlags = 0x10; +constexpr uint8_t kControlVisible = 1; // +0x10 bit 0: the window handles events only while shown + +// doLottoSelect (0x6d3fd0) modes. +constexpr uint32_t kSelectPrevious = 0; +constexpr uint32_t kSelectNext = 1; +constexpr uint32_t kSelectKeep = 2; +constexpr int kMaxRuneChoices = 256; // a bound for stepping through the choices + +// Prices by the number of runes given (0: nothing to buy, not shown). +constexpr uint32_t kPriceTable = 0x00ad2aac; +constexpr uint32_t kPrices[kSlots + 1] = {0, 1000000, 100000, 10000, 1000}; + +// Buying needs at least one rune: cmp dx, 2 -> 1 (imm8 at +3). +constexpr re::BytePatch kMinRuneCompares[] = { + {0x006d3e20, 3, 2}, // button colour + {0x006d4544, 3, 2}, // button state (0x6d4390) + {0x006d4a13, 3, 2}, // render +}; +constexpr uint8_t kMinRunes = 1; + +// The cursor's carried item (MouseCursor_Instance 0x6550f0, +0x68). +struct CarriedItem { + uint16_t kind; // 1: an item + uint16_t itemClass; + uint32_t id; + uint32_t rest[3]; +}; +static_assert(sizeof(CarriedItem) == 0x14); +constexpr uint16_t kCarriedItem = 1; +constexpr size_t kCursorCarried = 0x68; + +// The rune master window (one per session; set by its handlers). +uint8_t* g_window = nullptr; + +// The last rune each hero bought (by hero index), 0: none yet. +uint32_t g_lastRune[game::kChestInventory] = {}; + +template +T& Field(uint8_t* window, size_t offset) { + return *reinterpret_cast(window + offset); +} + +void* Cursor() { + return reinterpret_cast(0x006550f0)(); // MouseCursor_Instance (hud.cpp: shifted copy) +} + +CarriedItem* Carried() { + void* cursor = Cursor(); + return cursor ? *reinterpret_cast(static_cast(cursor) + kCursorCarried) : nullptr; +} + +// cMouseCursor::SetItem (0x655710, ret 0x20): takes the descriptor (game heap), +// as the rune master puts an item back on the cursor. +void CarryItem(const CarriedItem& item) { + auto* copy = static_cast(game::GameNew(sizeof(CarriedItem))); + if (!copy) { + return; + } + *copy = item; + using SetFn = void(__fastcall*)(void*, void*, void*, int, int, int, float, float, int, int); + reinterpret_cast(0x00655710)(Cursor(), nullptr, copy, 0, 0, 0, 1.0f, 1.0f, -32, -32); +} + +bool HitTest(uint8_t* control, int x, int y) { + using HitTestFn = char(__fastcall*)(void*, void*, int, int); + return reinterpret_cast((*reinterpret_cast(control))[kControlHitTest])(control, nullptr, x, + y) != 0; +} + +// The slot under (x, y), or -1. +int SlotAt(uint8_t* window, int x, int y) { + for (int slot = 0; slot < kSlots; ++slot) { + if (HitTest(window + kSlotControls + slot * kSlotStride, x, y)) { + return slot; + } + } + return -1; +} + +bool SlotFilled(uint8_t* window, int slot) { + return Field(window, kSlotItems + slot * kSlotStride) != 0; +} + +int RuneCount(uint8_t* window) { + int count = 0; + for (int slot = 0; slot < kSlots; ++slot) { + count += SlotFilled(window, slot) ? 1 : 0; + } + return count; +} + +int HeroIndex() { + void* hero = game::GetPlayerHero(); + const int index = hero ? game::CreatureField(hero, game::kCreatureId) : -1; + return index >= 0 && index < game::kChestInventory ? index : -1; +} + +// The hero's object type (hero +0x10), which doLottoSelect takes. +uint32_t HeroType() { + void* hero = game::GetPlayerHero(); + return hero ? game::ItemType(hero) : 0; +} + +uint8_t* HeroInventory() { + const int hero = HeroIndex(); + return hero >= 0 ? skillslots::Inventory(static_cast(hero)) : nullptr; +} + +// Free space for a 1 x 1 item in `inventory` (cInventoryEntry::FindFreeSpace 0x5dab60). +bool FindFreeSpace(uint8_t* inventory, uint16_t* cell) { + using FindFn = char(__fastcall*)(void*, void*, uint32_t, uint32_t, uint16_t*, uint32_t); + return reinterpret_cast(0x005dab60)(inventory, nullptr, 1, 1, cell, 0) != 0; +} + +void SetPrices() { + std::memcpy(reinterpret_cast(kPriceTable), kPrices, sizeof(kPrices)); +} + +void Select(uint8_t* window, uint32_t mode) { + using SelectFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t); + reinterpret_cast(0x006d3fd0)(window, nullptr, HeroType(), mode); // doLottoSelect +} + +// Shows a control (0x731e70) / hides it (0x731ee0); flag 1 = visible. +void SetShown(uint8_t* control, bool shown) { + using FlagFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlVisible); +} + +// Chooses the hero's last bought rune (steps through the choices). +void ChooseRemembered(uint8_t* window) { + const int hero = HeroIndex(); + const uint32_t remembered = hero >= 0 ? g_lastRune[hero] : 0; + for (int i = 0; remembered && i < kMaxRuneChoices && Field(window, kChosenRune) != remembered; ++i) { + Select(window, kSelectNext); + } +} + +// Where a bought rune of `type` goes in the hero's backpack: onto a stack +// with room (`stack`), else into a free cell (`cell`). False: no room. +bool FindRoom(uint8_t* inventory, uint32_t type, uint32_t* stack, uint16_t* cell) { + *stack = itemstack::StackWithRoom(inventory, type); + return *stack != 0 || FindFreeSpace(inventory, cell); +} + +// Buying is possible: runes given, a rune chosen, gold for the price +// (0x6d3ce0(0) checks it) and room in the backpack. +bool CanBuy(uint8_t* window) { + uint8_t* inventory = HeroInventory(); + const uint32_t rune = Field(window, kChosenRune); + uint32_t stack = 0; + uint16_t cell = 0; + using PayFn = char(__fastcall*)(void*, void*, uint32_t); + return RuneCount(window) >= kMinRunes && rune != 0 && inventory && FindRoom(inventory, rune, &stack, &cell) && + reinterpret_cast(0x006d3ce0)(window, nullptr, 0); +} + +// Buys the chosen rune for the runes in the slots and puts it into the +// backpack (CanBuy checked; 0x6d3ce0(1) pays). +void Buy(uint8_t* window) { + uint8_t* inventory = HeroInventory(); + const uint32_t rune = Field(window, kChosenRune); + uint32_t stack = 0; + uint16_t cell = 0; + using PayFn = char(__fastcall*)(void*, void*, uint32_t); + if (!FindRoom(inventory, rune, &stack, &cell) || !reinterpret_cast(0x006d3ce0)(window, nullptr, 1)) { + return; + } + for (int slot = 0; slot < kSlots; ++slot) { + auto* descriptor = &Field(window, kSlotItems + slot * kSlotStride); + if (descriptor->id) { + game::DestroyObject(descriptor->id); + *descriptor = {}; + } + } + if (stack) { + itemstack::SetStackCount(stack, itemstack::StackCount(stack) + 1); + } else { + using CreateFn = uint32_t(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t); + const uint32_t id = + reinterpret_cast(0x005fb590)(game::ObjectManager(), nullptr, rune, 0, 0, 0); // cObjectManager::Create + if (id) { + using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); + reinterpret_cast(0x005d9ec0)(inventory, nullptr, cell, id, 1, 1, 1); // cInventoryEntry::PlaceItem + } + } + if (const int hero = HeroIndex(); hero >= 0) { + g_lastRune[hero] = rune; + } + using RefreshFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x006d4390)(window, nullptr, 1); +} + +// A press on the buy button: buys if possible. True if the press was taken. +bool HandleBuy(uint8_t* window, int x, int y) { + if (!HitTest(window + kBuyButton, x, y)) { + return false; + } + if (CanBuy(window)) { + Buy(window); + } + return true; +} + +// A rune stack dropped on a slot: one rune goes in. Returns the handler's +// result, or -1 if this is no such drop. +int DropFromStack(uint8_t* window, void* event, int x, int y, + int(__fastcall* original)(void*, void*, void*)) { + CarriedItem* carried = Carried(); + const unsigned count = carried && carried->kind == kCarriedItem && carried->itemClass == game::kItemClassRune + ? itemstack::StackCount(carried->id) + : 1; + const int slot = count > 1 ? SlotAt(window, x, y) : -1; + if (slot < 0) { + return -1; + } + // The slot's old rune comes onto the cursor: the rest of the stack goes to + // the backpack, which needs room. + const bool exchange = SlotFilled(window, slot); + uint8_t* inventory = exchange ? HeroInventory() : nullptr; + uint16_t cell = 0; + if (exchange && (!inventory || !FindFreeSpace(inventory, &cell))) { + return 1; + } + const uint32_t single = game::CloneItem(carried->id); + if (!single) { + return -1; + } + const CarriedItem rest = *carried; + itemstack::SetStackCount(rest.id, count - 1); + carried->id = single; + const int result = original(window, nullptr, event); + if (exchange) { + using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t); + reinterpret_cast(0x005d9ec0)(inventory, nullptr, cell, rest.id, 1, 1, 1); // cInventoryEntry::PlaceItem + } else { + CarryItem(rest); + } + return result; +} + +struct EventCall { + uint8_t* window; + void* event; + int(__fastcall* original)(void*, void*, void*); +}; + +int EventBody(void* context) { + auto* call = static_cast(context); + g_window = call->window; + SetPrices(); + const bool visible = (call->window[kControlFlags] & kControlVisible) != 0; + if (visible && call->event && game::MouseEventKind(call->event) == game::MouseEvent::ButtonDown && + game::EventField(call->event, game::kMouseButton) == game::kLeftButton) { + const int x = game::EventField(call->event, game::kMouseButtonX); + const int y = game::EventField(call->event, game::kMouseButtonY); + if (HandleBuy(call->window, x, y)) { + return 1; + } + if (const int result = DropFromStack(call->window, call->event, x, y, call->original); result >= 0) { + return result; + } + } + return call->original(call->window, nullptr, call->event); +} + +// --- per hero: the last rune bought ----------------------------------------------- + +nlohmann::json WriteLastRune(unsigned hero) { + return hero < game::kChestInventory && g_lastRune[hero] ? nlohmann::json(g_lastRune[hero]) : nlohmann::json(); +} + +void ReadLastRune(unsigned hero, const nlohmann::json& data) { + if (hero < game::kChestInventory) { + g_lastRune[hero] = data.is_number_unsigned() ? data.get() : 0; + } +} + +void OnGameEnd() { + std::memset(g_lastRune, 0, sizeof(g_lastRune)); + g_window = nullptr; +} + +struct Registration { + Registration() { + inventory_save::RegisterHeroData("lastRune", &WriteLastRune, &ReadLastRune); + game::OnGameStart(&SetPrices); + game::OnGameEnd(&OnGameEnd); + } +} g_registration; + +void Startup() { + if (re::PatchAll(kMinRuneCompares, kMinRunes) != 0) { + re::Log("runetrade: rune count patches failed"); + } +} +RE_STARTUP(RuneTradeStartup, Startup); + +} // namespace + +// 0x6d3d50 (plain ret), after slot changes: the arrows always shown, a rune +// always chosen (the last one bought first), unless a bought rune is waiting. +void UpdateChoice(uint8_t* window) { + // doLottoSelect refreshes the window (0x6d4390), which comes back here + // while no rune is chosen (an empty choice list). + static thread_local bool updating = false; + g_window = window; + SetPrices(); + if (updating) { + return; + } + updating = true; + SetShown(window + kNextArrow, true); + SetShown(window + kPreviousArrow, true); + if (Field(window, kChosenRune) == 0) { + Select(window, kSelectKeep); + ChooseRemembered(window); + } + updating = false; +} + +} // namespace mods::runetrade + +namespace game { + +// cUI_MasterLotto::HandleEvent (0x6d54e0, ret 4); centred like the other +// windows (mods/hud.cpp). +int __fastcall UiMasterLotto_HandleEvent(void* self, void* edx, void* event); +RE_HOOK(0x006d54e0, UiMasterLotto_HandleEvent); +int __fastcall UiMasterLotto_HandleEvent(void* self, void*, void* event) { + mods::runetrade::EventCall call{static_cast(self), event, UiMasterLotto_HandleEvent_original}; + return mods::hud::HandleCentred(event, &mods::runetrade::EventBody, &call); +} + +// cUI_MasterLotto::UpdateChoice (0x6d3d50, plain ret): replaced. +void __fastcall UiMasterLotto_UpdateChoice(void* self, void* edx); +RE_HOOK(0x006d3d50, UiMasterLotto_UpdateChoice); +void __fastcall UiMasterLotto_UpdateChoice(void* self, void*) { + mods::runetrade::UpdateChoice(static_cast(self)); +} + +} // namespace game + +namespace mods::runetrade { + +// The buy button's colours (0x6d5d90, set by the render each frame): disabled +// while buying is not possible (also without room in the backpack). +const void* BuyButtonColours(void* button, const void* colours) { + if (!g_window || button != g_window + kBuyButton || CanBuy(g_window)) { + return colours; + } + Field(g_window, kBuyTextColour) = *reinterpret_cast(kDisabledTextColour); + return reinterpret_cast(kDisabledColours); +} + +} // namespace mods::runetrade + +namespace game { + +// cUI_Button colours (0x6d5d90, ret 4): (colour set) into +0x54..+0x60. +void __fastcall UiButton_SetColours(void* self, void* edx, const void* colours); +RE_HOOK(0x006d5d90, UiButton_SetColours); +void __fastcall UiButton_SetColours(void* self, void* edx, const void* colours) { + UiButton_SetColours_original(self, edx, mods::runetrade::BuyButtonColours(self, colours)); +} + +} // namespace game + +namespace mods::runetrade { +namespace { + +// The chosen rune's name below its icon (request 2026-10-17: the icons are +// small). The name is the item's (0x44a420, thiscall: text of the item type's +// name id, as the tooltips start with it). +constexpr uint16_t kNameFont = 1; +constexpr uint32_t kNameColour = 0xffe6d6aa; // as the tab headers' normal text (cUI_TabControl::Render) +constexpr int kNameGap = 4; +constexpr int kNameWidth = 320; + +const wchar_t* ItemName(void* item) { + return reinterpret_cast(0x0044a420)(item, nullptr); +} + +void DrawChosenName(uint8_t* window, void* renderer) { + const uint32_t object = Field(window, kChosenObject); + void* item = object && Field(window, kChosenRune) ? game::GetObjectById(object) : nullptr; + const wchar_t* name = item ? ItemName(item) : nullptr; + if (!name || !*name) { + return; + } + const game::UiRect icon = game::ControlScreenRect(window + kChosenControl); + const game::UiRect rect = {icon.x + icon.w / 2 - kNameWidth / 2, icon.y + icon.h + kNameGap, + static_cast(kNameWidth), + static_cast(game::FontLineHeight(kNameFont) + kNameGap)}; + game::DrawText(renderer, kNameFont, rect, name, kNameColour, game::kTextCenter); +} + +} // namespace + +struct RenderCall { + uint8_t* window; + void* renderer; + int(__fastcall* original)(void*, void*, void*); +}; + +int RenderBody(void* context) { + auto* call = static_cast(context); + const int result = call->original(call->window, nullptr, call->renderer); + if ((call->window[kControlFlags] & kControlVisible) != 0) { + DrawChosenName(call->window, call->renderer); + } + return result; +} + +} // namespace mods::runetrade + +namespace game { + +// cUI_MasterLotto::Render (0x6d4720, ret 4); centred like the other windows. +int __fastcall UiMasterLotto_Render(void* self, void* edx, void* renderer); +RE_HOOK(0x006d4720, UiMasterLotto_Render); +int __fastcall UiMasterLotto_Render(void* self, void*, void* renderer) { + mods::runetrade::RenderCall call{static_cast(self), renderer, UiMasterLotto_Render_original}; + return mods::hud::RenderCentred(self, &mods::runetrade::RenderBody, &call); +} + +} // namespace game diff --git a/src/mods/sharedchest.cpp b/src/mods/sharedchest.cpp new file mode 100644 index 0000000..fd0b291 --- /dev/null +++ b/src/mods/sharedchest.cpp @@ -0,0 +1,410 @@ +// 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 +#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 "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(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() { + 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 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", "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(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 diff --git a/src/mods/sharedchest.h b/src/mods/sharedchest.h new file mode 100644 index 0000000..ee604f7 --- /dev/null +++ b/src/mods/sharedchest.h @@ -0,0 +1,8 @@ +#pragma once + +namespace mods::sharedchest { + +// Writes the shared chest (save\shared_chest.json); called after a hero is saved. +void Save(); + +} // namespace mods::sharedchest diff --git a/src/mods/skillslots.cpp b/src/mods/skillslots.cpp index ab68cc6..2b11a63 100644 --- a/src/mods/skillslots.cpp +++ b/src/mods/skillslots.cpp @@ -13,7 +13,7 @@ // Network events sent from the shadow would name slot - 5: multiplayer with // more than 5 slots is not supported. -#include "mods/potionstack.h" +#include "mods/itemstack.h" #include "mods/skillslots.h" #include @@ -203,7 +203,7 @@ void __fastcall Inv_ReplaceItemId(void* self, void* edx, int oldId, int newId); RE_HOOK(0x005d80e0, Inv_ReplaceItemId); void __fastcall Inv_ReplaceItemId(void* self, void*, int oldId, int newId) { WithShadow(Inv_ReplaceItemId_original, self, oldId, newId); - mods::potionstack::OnItemIdReplaced(static_cast(oldId), static_cast(newId)); + mods::itemstack::OnItemIdReplaced(static_cast(oldId), static_cast(newId)); } void __fastcall Inv_FixInventory(void* self, void* edx); diff --git a/tools/names.txt b/tools/names.txt index 1fc6d7f..041a087 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -163,7 +163,7 @@ 0x00539ce0 cCreature::StartArtCooldown # AddEffect(0x22, ...) 0x004feef0 cCreature::ArtCastEffect # per-art effect of using an art (switch on art id) 0x005fbaa0 cObjectManager::CreateAt # (type, position, ...): object placed in the world -0x005fb590 cObjectManager::CreateFX +0x005fb590 cObjectManager::Create # (type, 0, 0, 0) -> id; loadHero creates items with it 0x005fb040 cObjectManager::Allocate 0x004514d0 ObjectFactory::Create # (class, ...) 0x005fcd10 cObjectManager::Place # (id, position); refuses dead objects (+0x14 & 0x10) @@ -205,3 +205,43 @@ 0x00580fe0 cCreatureArts::ChooseSkill # (skill id) 0x00579560 cCreatureArts::Recalculate # (0, 1) after point changes 0x0072af90 cUI_Button::Render +0x006ac8b0 cUI_Chest::Render # vf14: DrawItems on window +0x158 (inventory 0x11) +0x006acc70 cUI_Chest::HandleEvent # vf10: grid clicks via +0x158; Shift+click -> MoveItem(0x11, hero) +0x005dec80 cInventoryManager::MoveItem # (from, to, id, ?): into free space of `to` +0x0060d5e0 cEngine::~cEngine +0x006ac7f0 cUI_Chest::cUI_Chest +0x00728ed0 cUI_TabSheet::cUI_TabSheet # (name, rect, parent, 5), ret 0x18 +0x007290d0 cUI_TabSheet::AddTab # (text id, page), ret 8 +0x007290b0 cUI_TabSheet::SetMode +0x007291d0 cUI_TabSheet::SetActive +0x007291f0 cUI_TabSheet::GetActive +0x00729210 cUI_TabSheet::Layout # headers in a row above the sheet +0x00729000 cUI_TabSheet::HandleEvent +0x00729080 cUI_TabSheet::Render +0x007278f0 cUI_TabControl::cUI_TabControl +0x007286e0 cUI_TabControl::AddTab +0x00728af0 cUI_TabControl::SetActive # hides the old page, shows the new (vf24) +0x00728090 cUI_TabControl::Render +0x006728c0 TextManager_Instance +0x00672f70 TextManager::GetText # (id) -> {begin, end, cap} wide string +0x008485e2 operator_new +0x00727120 cUI_Window2::AddControl +0x0043f5f0 ItemIsConsumable # (type): class 9 (potion) or 0x1c (rune) +0x006d54e0 cUI_MasterLotto::HandleEvent # rune master: 4 rune slots +0x00655710 cMouseCursor::SetItem # (descriptor, 0,0,0, 1f,1f,-32,-32), ret 0x20 +0x006e6560 cUI_Tooltip::~cUI_Tooltip +0x006d3d50 cUI_MasterLotto::UpdateChoice # after slot changes: arrows, chosen rune (+0x7b4) +0x006d3fd0 cUI_MasterLotto::doLottoSelect # (hero type, 0 prev / 1 next / 2 keep), ret 8 +0x006d42a0 cUI_MasterLotto::Buy +0x006d4390 cUI_MasterLotto::Refresh # (char), ret 4 +0x006d3ce0 cUI_MasterLotto::TryPay # (pay): gold >= price[runes]; pays if pay +0x006d3c90 cUI_MasterLotto::RuneCount +0x006d4180 cUI_MasterLotto::RandomRune # (hero type or 0), ret 4 +0x006d4720 cUI_MasterLotto::Render +0x0057fc40 cCreatureArts::AddGold # (amount; negative pays) +0x006d5d90 cUI_Button::SetColours # (colour set) -> +0x54..+0x60, ret 4 +0x0044a420 cItemBase::GetName # wide text of the type's name id (or +0x3c) +0x004ec3a0 cCreature::UpdateCollect # mode 0xc; substate 0 searches rings of cells (cmp edi,-20 at 0x4ec8c6) +0x004ec170 cCreature::CanCollectItem # (item object), ret 4; pickup filter, no distance check +0x00592ef0 WideString_Append # thiscall (first, last, 0), ret 0xc; {begin,end,cap} +0x00589270 cCreatureArts::ArtTooltip # (art, text*, flag), ret 0xc