Shared chest tabs, rune stacks and rune master, Q pickup, Kettenblitz tooltip and range

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-17 03:40:42 +02:00
1 parent 3d9d864c25
commit 6ca96d54b4
29 files changed
+2006 -171

No files matched your search

+6 -1
View File
@@ -35,6 +35,9 @@ add_library(sacred_re SHARED
src/game/engine/events.cpp src/game/engine/events.cpp
src/game/engine/fx.cpp src/game/engine/fx.cpp
src/game/engine/orders.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/borderless.cpp
src/mods/widescreen.cpp src/mods/widescreen.cpp
src/mods/hud.cpp src/mods/hud.cpp
@@ -44,7 +47,8 @@ add_library(sacred_re SHARED
src/mods/skillslots.cpp src/mods/skillslots.cpp
src/mods/inventory_save.cpp src/mods/inventory_save.cpp
src/mods/potioncounts.cpp src/mods/potioncounts.cpp
src/mods/potionstack.cpp src/mods/itemstack.cpp
src/mods/sharedchest.cpp
src/mods/skillbar.cpp src/mods/skillbar.cpp
src/mods/arttarget.cpp src/mods/arttarget.cpp
src/mods/aura_restore.cpp src/mods/aura_restore.cpp
@@ -57,6 +61,7 @@ add_library(sacred_re SHARED
src/mods/recordcache.cpp src/mods/recordcache.cpp
src/mods/megamap.cpp src/mods/megamap.cpp
src/mods/respec.cpp src/mods/respec.cpp
src/mods/runetrade.cpp
src/mods/guards.cpp src/mods/guards.cpp
) )
target_include_directories(sacred_re PRIVATE src) target_include_directories(sacred_re PRIVATE src)
+24
View File
@@ -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 (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 (squared 100000, about 316). It is the one place to make the range dynamic
later. Jumps do not check the ground in between. 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").
+7
View File
@@ -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 answers below 100000 to the quest script (0x46bd80). Esc does the same with
answer 0xffffffff: the dialog closes, nothing is chosen. (Hiding the tooltip answer 0xffffffff: the dialog closes, nothing is chosen. (Hiding the tooltip
alone left the input lock; measured with a write watch on its flags.) 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.
+28 -2
View File
@@ -1,4 +1,4 @@
# Potion counts and potion stacks # Item stacks (potions, combat-art runes)
## Requests (2026-10-17) ## 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 - `src/mods/potioncounts.cpp`: counts per kind (stacks added up), drawn
bottom-right on each potion slot after `RenderPotionSlots`. 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 saved as `"count"` on the grid cell in the JSON inventories
(`inventory_save.cpp`); entry pours into stacks with room on the next (`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 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) 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 never stacks. Selling a stack to a trader still has to be handled (pay
count x price, the trader keeps singles). 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.
+54
View File
@@ -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.
+71
View File
@@ -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.
+2 -2
View File
@@ -119,7 +119,7 @@ in-game texts/tooltips that name the old keys):
| Key | Action | Temporary key (sacred_re.ini) | | Key | Action | Temporary key (sacred_re.ini) |
|---|---|---| |---|---|---|
| W | action slot 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | G (`[keys] ActionSlot3`) | | 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`) | | S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) |
| D | unbound | - | | 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 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 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 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).
+40
View File
@@ -17,6 +17,16 @@ std::vector<SaveGameLoadedFn>& LoadedCallbacks() {
return callbacks; return callbacks;
} }
std::vector<GameEndFn>& GameEndCallbacks() {
static std::vector<GameEndFn> callbacks;
return callbacks;
}
std::vector<GameStartFn>& GameStartCallbacks() {
static std::vector<GameStartFn> callbacks;
return callbacks;
}
} // namespace } // namespace
void OnEngineTick(EngineTickFn callback) { void OnEngineTick(EngineTickFn callback) {
@@ -27,6 +37,14 @@ void OnSaveGameLoaded(SaveGameLoadedFn callback) {
LoadedCallbacks().push_back(callback); LoadedCallbacks().push_back(callback);
} }
void OnGameEnd(GameEndFn callback) {
GameEndCallbacks().push_back(callback);
}
void OnGameStart(GameStartFn callback) {
GameStartCallbacks().push_back(callback);
}
void NotifySaveGameLoaded() { void NotifySaveGameLoaded() {
for (SaveGameLoadedFn callback : LoadedCallbacks()) { for (SaveGameLoadedFn callback : LoadedCallbacks()) {
callback(); 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 } // namespace game
+10
View File
@@ -16,4 +16,14 @@ using SaveGameLoadedFn = void (*)();
void OnSaveGameLoaded(SaveGameLoadedFn callback); void OnSaveGameLoaded(SaveGameLoadedFn callback);
void NotifySaveGameLoaded(); 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 } // namespace game
+51
View File
@@ -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<SerializeFn>((*static_cast<void***>(item))[0x1c / 4]);
}
} // namespace
std::vector<uint8_t> SerializeItem(void* item) {
std::vector<uint8_t> data;
MemoryStream memory = MakeMemoryStream(data);
ItemSerializer(item)(item, nullptr, AsStream(memory), 1, 0);
return data;
}
uint32_t CreateItem(uint32_t type, const std::vector<uint8_t>& 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<CreateFn>(0x005fb590)(manager, nullptr, type, 0, 0, 0) : 0;
auto* item = static_cast<uint8_t*>(id ? GetObjectById(id) : nullptr); // cObjectManager::Create
if (!item) {
return 0;
}
std::vector<uint8_t> 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<uint32_t*>(item + kObjectFlags);
*reinterpret_cast<uint32_t*>(item + 0xc) = id;
flags &= ~0x200000u;
if (flags & 2) {
flags &= ~0x30u;
}
flags &= ~0x10u;
item[kItemOwner] = static_cast<uint8_t>(ownerHeroId);
return id;
}
uint32_t CloneItem(uint32_t id) {
auto* item = static_cast<uint8_t*>(id ? GetObjectById(id) : nullptr);
return item ? CreateItem(ItemType(item), SerializeItem(item), item[kItemOwner]) : 0;
}
} // namespace game
+44
View File
@@ -24,6 +24,28 @@ inline int ItemPotionKind(uint32_t type) {
return table ? reinterpret_cast<Fn>(0x0043f630)(table, nullptr, type) : 0; return table ? reinterpret_cast<Fn>(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<const uint8_t* const*>(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<bool(__stdcall*)(uint32_t)>(0x0043f5f0)(type);
}
inline uint32_t ItemType(const void* item) { inline uint32_t ItemType(const void* item) {
return *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(item) + kItemType); return *reinterpret_cast<const uint32_t*>(static_cast<const uint8_t*>(item) + kItemType);
} }
@@ -74,3 +96,25 @@ void ForEachGridItem(const uint8_t* inventory, Fn fn) {
} }
} // namespace game } // namespace game
#include <vector>
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<uint8_t> 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<uint8_t>& 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
+72
View File
@@ -0,0 +1,72 @@
#include "game/engine/stream.h"
#include <cstring>
#include <stdexcept>
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<uint32_t>(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<uint32_t>(self->data->size());
}
uint32_t __fastcall MemTell(MemoryStream* self, void*) {
return self->position;
}
void* g_memoryVtable[8] = {
reinterpret_cast<void*>(&MemDestroy), reinterpret_cast<void*>(&MemRead),
reinterpret_cast<void*>(&MemWrite), reinterpret_cast<void*>(&MemSeek),
reinterpret_cast<void*>(&MemSize), reinterpret_cast<void*>(&MemTell),
reinterpret_cast<void*>(0x0065afb0), reinterpret_cast<void*>(0x0065af80), // cFile helpers
};
} // namespace
MemoryStream MakeMemoryStream(std::vector<uint8_t>& 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<uint8_t>(c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10);
};
for (size_t i = 0; i < size; ++i) {
out[i] = static_cast<uint8_t>(nibble(s[2 * i]) << 4 | nibble(s[2 * i + 1]));
}
}
} // namespace game
+50
View File
@@ -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 <cstdint>
#include <string>
#include <vector>
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<Fn>(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<Fn>(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<Fn>(stream->vtable[3])(stream, nullptr, position);
}
inline uint32_t Tell(Stream* stream) {
using Fn = uint32_t(__fastcall*)(Stream*, void*);
return reinterpret_cast<Fn>(stream->vtable[5])(stream, nullptr);
}
// A cFile over a byte vector (reads from / writes at `position`).
struct MemoryStream {
void** vtable;
std::vector<uint8_t>* data;
uint32_t position;
};
MemoryStream MakeMemoryStream(std::vector<uint8_t>& data);
inline Stream* AsStream(MemoryStream& memory) {
return reinterpret_cast<Stream*>(&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
+82
View File
@@ -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 <cstddef>
#include <cstdint>
#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<void*(__cdecl*)(size_t)>(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<ConstructFn>(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<AddFn>(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<SetFn>(0x007291d0)(sheet, nullptr, index);
}
inline uint8_t* TabControl(void* sheet) {
return *reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(sheet) + kTabSheetControl);
}
// The tab entries of a cUI_TabControl.
inline uint8_t* TabEntries(uint8_t* control) {
return *reinterpret_cast<uint8_t**>(control + kTabControlTabs);
}
inline size_t TabCount(uint8_t* control) {
uint8_t* begin = TabEntries(control);
uint8_t* end = *reinterpret_cast<uint8_t**>(control + kTabControlTabs + 4);
return begin ? static_cast<size_t>(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<AddFn>(0x00727120)(window, nullptr, control);
}
} // namespace game
+111
View File
@@ -0,0 +1,111 @@
#include "game/ui/texts.h"
#include <windows.h>
#include <cwchar>
#include <cwctype>
#include <mutex>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#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<GameString>& Texts() {
static std::vector<GameString> texts;
return texts;
}
std::unordered_set<uint32_t>& CapitalizedIds() {
static std::unordered_set<uint32_t> ids;
return ids;
}
// By id; nodes stay put, so the strings handed out stay valid.
std::unordered_map<uint32_t, Capitalized> 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<uint32_t>(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<const GameString*>(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
+18
View File
@@ -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 <cstdint>
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
+128 -9
View File
@@ -11,7 +11,7 @@
// //
// This mod keeps all of that and decides: // This mod keeps all of that and decides:
// - hits per chain N = 3 + level / 5: the first bolt's [14]; // - 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 // 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 // chain has not hit yet; else another enemy; else the current target again
// (a lone enemy takes all N hits); // (a lone enemy takes all N hits);
@@ -23,6 +23,7 @@
#include <cstring> #include <cstring>
#include <map> #include <map>
#include <memory> #include <memory>
#include <string>
#include <vector> #include <vector>
#include <windows.h> #include <windows.h>
@@ -31,6 +32,7 @@
#include "game/engine/fx.h" #include "game/engine/fx.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "re/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.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, // 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 // 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 // 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, // reached is the bolt created on it. A jump may go as far as a cast.
// so it can become dynamic (levels, attributes) later. // The segment count is bound by the creature's array of segment ids
constexpr double kRaySegmentLength = 30.0; // (+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; constexpr int kRaySegments = 20;
double CastRange() { double CastRange() {
return kRaySegmentLength * kRaySegments; return static_cast<double>(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 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 kCreaturePosition = 0x18; // 4 dwords, as effects are placed
constexpr size_t kEventHitsLeft = 14; 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) { unsigned ArtLevel(void* creature, uint16_t art) {
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art); uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0; 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. // `current`; one not in `avoid` if there is any.
uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& avoid) { uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& 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() { void Forget() {
@@ -160,8 +184,8 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
} }
Forget(); Forget();
const unsigned level = ArtLevel(caster, g_heroArt); const unsigned level = ArtLevel(caster, g_heroArt);
const uint32_t hits = 3 + level / 5; const uint32_t hits = Hits(level);
const unsigned chains = 1 + level / 10; const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits; event[kEventHitsLeft] = hits;
auto chain = std::make_shared<Chain>(); auto chain = std::make_shared<Chain>();
chain->hit.push_back(target); chain->hit.push_back(target);
@@ -200,6 +224,18 @@ __declspec(naked) void NextTargetThunk() {
void Startup() { void Startup() {
const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk)); const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk));
re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)"); 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<uint32_t>(kRaySegmentLengthOriginal),
static_cast<uint32_t>(kRaySegmentLength)) &&
re::PatchPushImm32(kRayStep, bits(kRaySegmentLengthOriginal), bits(kRaySegmentLength));
re::Log("chainlightning: cast range %.0f%s", CastRange(), range ? "" : " (patch FAILED)");
} }
RE_STARTUP(ChainLightningStartup, Startup); RE_STARTUP(ChainLightningStartup, Startup);
@@ -237,3 +273,86 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
} }
} // namespace mods::chainlightning } // 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<Fn>(0x00592ef0)(text, nullptr, first, last, 0);
}
void SetText(void* text, const std::wstring& value) {
auto** string = static_cast<wchar_t**>(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<int>(CastRange())) + L"\n";
out += prefix + L"Sprungweite: " + std::to_wstring(static_cast<int>(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<uint16_t>(art) != kArtBlitzschlag) {
return;
}
auto* const* string = static_cast<wchar_t* const*>(text);
if (!string[0] || string[1] <= string[0]) {
return;
}
void* creature = static_cast<uint8_t*>(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
+19 -2
View File
@@ -489,6 +489,16 @@ int VisibleDialogTooltipsImpl(void** out, int max) {
return count; 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) { int KnownTooltipOffset(void* tooltip) {
for (const TooltipState& t : g_tooltips) { for (const TooltipState& t : g_tooltips) {
if (t.tooltip == tooltip) { if (t.tooltip == tooltip) {
@@ -598,8 +608,7 @@ HUD_EVENT(0x006d1080, UiMaster_HandleEvent, Anchor::Center)
HUD_RENDER(0x006d0f00, UiMaster_Render, Anchor::Center) HUD_RENDER(0x006d0f00, UiMaster_Render, Anchor::Center)
HUD_EVENT(0x006d2450, UiMasterCombo_HandleEvent, Anchor::Center) HUD_EVENT(0x006d2450, UiMasterCombo_HandleEvent, Anchor::Center)
HUD_RENDER(0x006d2b70, UiMasterCombo_Render, Anchor::Center) HUD_RENDER(0x006d2b70, UiMasterCombo_Render, Anchor::Center)
HUD_EVENT(0x006d54e0, UiMasterLotto_HandleEvent, Anchor::Center) // cUI_MasterLotto (rune master): event and render in mods/runetrade.cpp.
HUD_RENDER(0x006d4720, UiMasterLotto_Render, Anchor::Center)
HUD_EVENT(0x006d5e20, UiMasterPortal_HandleEvent, Anchor::Center) HUD_EVENT(0x006d5e20, UiMasterPortal_HandleEvent, Anchor::Center)
HUD_RENDER(0x006d5e00, UiMasterPortal_Render, Anchor::Center) HUD_RENDER(0x006d5e00, UiMasterPortal_Render, Anchor::Center)
HUD_EVENT(0x006dab10, UiMerchandise_HandleEvent, 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); 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 game
namespace mods::hud { namespace mods::hud {
+75 -15
View File
@@ -20,17 +20,20 @@
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art) // SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set // WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
// ActionSlot3=G ; what W did (W is movement) // 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) // SaveGame=K ; what S did (save game window)
#include "mods/input.h" #include "mods/input.h"
#include <atomic>
#include <cctype> #include <cctype>
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <string> #include <string>
#include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "mods/skillbar.h" #include "mods/skillbar.h"
#include "mods/skillkeys.h" #include "mods/skillkeys.h"
#include "mods/wasd.h" #include "mods/wasd.h"
@@ -43,7 +46,7 @@ namespace mods::input {
namespace { namespace {
// --- sacred.exe --------------------------------------------------------------- // --- 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 kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it
constexpr uint32_t kEventKeyVtable = 0x00895568; constexpr uint32_t kEventKeyVtable = 0x00895568;
@@ -75,7 +78,7 @@ void PostGameKey(char key, bool pressed) {
// --- bindings ------------------------------------------------------------------ // --- bindings ------------------------------------------------------------------
enum class Kind { SkillSlot, WeaponSet, GameKey }; enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp };
struct Binding { struct Binding {
const char* name; const char* name;
@@ -102,7 +105,7 @@ Binding g_bindings[] = {
{"WeaponSet6", "F6", Kind::WeaponSet, 5, 0}, {"WeaponSet6", "F6", Kind::WeaponSet, 5, 0},
{"WeaponSet7", "F7", Kind::WeaponSet, 6, 0}, {"WeaponSet7", "F7", Kind::WeaponSet, 6, 0},
{"ActionSlot3", "G", Kind::GameKey, 0, 'W'}, {"ActionSlot3", "G", Kind::GameKey, 0, 'W'},
{"PickUp", "V", Kind::GameKey, 0, 'A'}, {"PickUp", "Q", Kind::PickUp, 0, 0},
{"SaveGame", "K", Kind::GameKey, 0, 'S'}, {"SaveGame", "K", Kind::GameKey, 0, 'S'},
}; };
@@ -147,6 +150,37 @@ bool Reserved(int vk) {
return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(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<bool> 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) { void Press(const Binding& binding, int vk) {
const auto slot = static_cast<unsigned>(binding.index); const auto slot = static_cast<unsigned>(binding.index);
switch (binding.kind) { switch (binding.kind) {
@@ -159,6 +193,9 @@ void Press(const Binding& binding, int vk) {
case Kind::GameKey: case Kind::GameKey:
PostGameKey(binding.gameKey, true); PostGameKey(binding.gameKey, true);
break; break;
case Kind::PickUp:
g_pickUp = true;
break;
} }
} }
@@ -168,6 +205,7 @@ void Release(const Binding& binding, int vk) {
mods::skillkeys::KeyReleased(vk); mods::skillkeys::KeyReleased(vk);
break; break;
case Kind::WeaponSet: case Kind::WeaponSet:
case Kind::PickUp:
break; break;
case Kind::GameKey: case Kind::GameKey:
PostGameKey(binding.gameKey, false); PostGameKey(binding.gameKey, false);
@@ -184,23 +222,45 @@ bool g_swallowChar = false;
void Startup() { void Startup() {
LoadBindings(); LoadBindings();
if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) {
re::Log("input: pickup range patch FAILED");
}
re::Log("input: key bindings active"); re::Log("input: key bindings active");
} }
RE_STARTUP(InputStartup, Startup); 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<uint8_t**>(kUiManager);
auto* dialog = ui ? *reinterpret_cast<uint8_t**>(ui + kUiDialog) : nullptr;
if (!dialog || (*reinterpret_cast<uint32_t*>(dialog + kControlFlags) & kControlVisible) == 0) {
return nullptr;
}
void* focus = *reinterpret_cast<void**>(ui + kUiFocus);
auto* edit = static_cast<uint8_t*>(focus ? game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl)
: nullptr);
if (!edit || (*reinterpret_cast<uint32_t*>(edit + kControlFlags) & kEditReadOnly) != 0) {
return nullptr;
}
return edit;
}
} // namespace } // namespace
bool TypingIntoTextField() { bool TypingIntoTextField() {
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager); return FocusedTextField() != nullptr;
if (!ui) {
return false;
}
void* focus = *reinterpret_cast<void**>(ui + 0x148);
if (!focus) {
return false;
}
auto* edit = static_cast<uint8_t*>(game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl));
return edit && ((*reinterpret_cast<uint32_t*>(edit + 0x10) >> 16) & 1) == 0;
} }
bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { 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; const bool repeat = (lParam & (1 << 30)) != 0;
if (!repeat) { if (!repeat) {
g_taken[vk] = false; g_taken[vk] = false;
if (TypingIntoTextField()) { if (FocusedTextField()) {
return false; return false;
} }
const Binding* binding = BindingFor(vk); const Binding* binding = BindingFor(vk);
+75 -99
View File
@@ -26,7 +26,10 @@
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/items.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 "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -36,81 +39,64 @@ namespace {
using nlohmann::json; using nlohmann::json;
using namespace mods::skillslots; 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<HeroData>& HeroDataList() {
static std::vector<HeroData> 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<int>(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<unsigned>(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<unsigned>(hero), all.contains(data.key) ? all.at(data.key) : json());
}
}
constexpr char kMagic[4] = {'S', 'R', 'I', 'J'}; constexpr char kMagic[4] = {'S', 'R', 'I', 'J'};
constexpr int kVersion = 1; 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<Fn>(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<Fn>(stream->vtable[2])(stream, nullptr, buffer, size);
}
void Seek(Stream* stream, uint32_t position) {
using Fn = void(__fastcall*)(Stream*, void*, uint32_t);
reinterpret_cast<Fn>(stream->vtable[3])(stream, nullptr, position);
}
uint32_t Tell(Stream* stream) {
using Fn = uint32_t(__fastcall*)(Stream*, void*);
return reinterpret_cast<Fn>(stream->vtable[5])(stream, nullptr);
}
// A cFile over a byte vector.
struct MemoryStream {
void** vtable;
std::vector<uint8_t>* 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<uint32_t>(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<uint32_t>(self->data->size());
}
uint32_t __fastcall MemTell(MemoryStream* self, void*) {
return self->position;
}
void* g_memoryVtable[8] = {
reinterpret_cast<void*>(&MemDestroy), reinterpret_cast<void*>(&MemRead),
reinterpret_cast<void*>(&MemWrite), reinterpret_cast<void*>(&MemSeek),
reinterpret_cast<void*>(&MemSize), reinterpret_cast<void*>(&MemTell),
reinterpret_cast<void*>(0x0065afb0), reinterpret_cast<void*>(0x0065af80), // cFile helpers
};
MemoryStream MakeMemoryStream(std::vector<uint8_t>& data) {
return MemoryStream{g_memoryVtable, &data, 0};
}
// --- block <-> JSON ------------------------------------------------------------ // --- block <-> JSON ------------------------------------------------------------
// The block Serialize writes, in order. // The block Serialize writes, in order.
@@ -151,28 +137,6 @@ public:
std::vector<uint8_t> data; std::vector<uint8_t> 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<uint8_t>(c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10);
};
for (size_t i = 0; i < size; ++i) {
out[i] = static_cast<uint8_t>(nibble(s[2 * i]) << 4 | nibble(s[2 * i + 1]));
}
}
bool AllZero(const uint8_t* p, size_t size) { bool AllZero(const uint8_t* p, size_t size) {
for (size_t i = 0; i < size; ++i) { for (size_t i = 0; i < size; ++i) {
if (p[i]) { if (p[i]) {
@@ -245,8 +209,8 @@ json ToJson(const std::vector<uint8_t>& block, const uint8_t* shadow) {
const uint8_t* cell = cells + i * kGridCellSize; const uint8_t* cell = cells + i * kGridCellSize;
if (!AllZero(cell, kGridCellSize)) { if (!AllZero(cell, kGridCellSize)) {
json entry = {{"cell", i}, {"item", U32At(cell)}, {"data", Hex(cell + 4, kGridCellSize - 4)}}; json entry = {{"cell", i}, {"item", U32At(cell)}, {"data", Hex(cell + 4, kGridCellSize - 4)}};
if (const unsigned count = mods::potionstack::StackCount(U32At(cell)); count > 1) { if (const unsigned count = mods::itemstack::StackCount(U32At(cell)); count > 1) {
entry["count"] = count; // a potion stack (src/mods/potionstack.cpp) entry["count"] = count; // a potion stack (src/mods/itemstack.cpp)
} }
grid.push_back(entry); grid.push_back(entry);
} }
@@ -357,6 +321,10 @@ std::vector<uint8_t> ToBlock(const json& j) {
} // namespace } // namespace
// Defined in namespace game below. // 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 mods::inventory_save
namespace game { namespace game {
@@ -380,13 +348,15 @@ int __fastcall InvManager_Serialize(void* self, void* edx, void* stream, int wri
++g_heroFileDepth; ++g_heroFileDepth;
g_readJson = g_readVanilla = 0; g_readJson = g_readVanilla = 0;
if (!static_cast<char>(write)) { if (!static_cast<char>(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); const int result = InvManager_Serialize_original(self, edx, stream, write, x);
--g_heroFileDepth; --g_heroFileDepth;
if (!static_cast<char>(write)) { if (!static_cast<char>(write)) {
re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla); re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla);
NotifySaveGameLoaded(); NotifySaveGameLoaded();
} else {
mods::sharedchest::Save(); // in-game saves write the save game, not saveHero
} }
return result; return result;
} }
@@ -398,6 +368,9 @@ uint32_t __cdecl ItemDataMgr_SaveHero(uint32_t a, uint32_t b, uint32_t c, uint32
++g_heroFileDepth; ++g_heroFileDepth;
const uint32_t result = ItemDataMgr_SaveHero_original(a, b, c, d, e, f); const uint32_t result = ItemDataMgr_SaveHero_original(a, b, c, d, e, f);
--g_heroFileDepth; --g_heroFileDepth;
if (result) {
mods::sharedchest::Save(); // with every saved hero, the shared chest too
}
return result; return result;
} }
@@ -423,7 +396,9 @@ int __fastcall Inv_Serialize(void* self, void* edx, void* streamArg, int write)
std::vector<uint8_t> block; std::vector<uint8_t> block;
MemoryStream memory = MakeMemoryStream(block); MemoryStream memory = MakeMemoryStream(block);
Inv_Serialize_original(self, edx, &memory, 1); 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<uint32_t>(text.size()); const auto length = static_cast<uint32_t>(text.size());
Write(stream, kMagic, 4); Write(stream, kMagic, 4);
Write(stream, &length, 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); MemoryStream memory = MakeMemoryStream(block);
Inv_Serialize_original(self, edx, &memory, 0); // also clears the shadow (Clear) Inv_Serialize_original(self, edx, &memory, 0); // also clears the shadow (Clear)
++g_readJson; ++g_readJson;
ReadHeroData(j, self);
for (const json& cell : j.at("grid")) { for (const json& cell : j.at("grid")) {
if (cell.contains("count")) { if (cell.contains("count")) {
const size_t i = cell.at("cell"); const size_t i = cell.at("cell");
mods::potionstack::SetStackCount(game::GridItem(static_cast<uint8_t*>(self), i), cell.at("count")); mods::itemstack::SetStackCount(game::GridItem(static_cast<uint8_t*>(self), i), cell.at("count"));
} }
} }
for (unsigned s = kGameSlots; s < kSlots; ++s) { for (unsigned s = kGameSlots; s < kSlots; ++s) {
+16
View File
@@ -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 <nlohmann/json.hpp>
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
@@ -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 // Items of the same type that the game counts as consumable (potions, runes;
// stack is the one potion object in the cell plus a count kept here (by item // ItemIsConsumable 0x43f5f0) share one inventory cell, up to kMaxStack. Such
// id, with the item type to tell a reused id apart) and saved with the JSON // items differ only in their object state (measured for runes), so the type
// inventories (src/mods/inventory_save.cpp). // 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); // Stacks exist in the players' inventories and the chest (inventory 0x11);
// trader stock waits for stack prices. // 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 // cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other
// stacks of its type that have room, on the next engine tick (the caller // 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 // still uses the object while placing it). An emptied object is removed and
// destroyed. // destroyed.
// - Sort button (cInventoryEntry::Sort 0x5db670, the inventory window's // - Sort button (cInventoryEntry::Sort 0x5db670, the inventory window's
// button): all potions of a type are poured together first. // button): all items of a type are poured together first.
// - Drinking (cCreature::UseItem 0x54a8f0, measured): the potion's event // - Use (cCreature::UseItem 0x54a8f0, measured for potions; runes, class 0x1c,
// handler (cPotion3D::vf18) applies the effect and marks it used up; UseItem // take the same path): the item's event handler (vf18) applies the effect
// then removes it from the grid (RemoveItem 0x5da5f0) and destroys it // and marks it used up; UseItem then removes it from the grid (RemoveItem
// (cObjectManager::DestroyObject 0x5fbe10), and cEngine::UseActionSlot // 0x5da5f0) and destroys it (cObjectManager::DestroyObject 0x5fbe10), and
// removes it once more. For a stack of more than one, the first removal // cEngine::UseActionSlot removes it once more. For a stack of more than
// takes one potion off the count instead, and the rest of that drink leaves // one, the first removal takes one off the count instead, and the rest of
// the object alone. // that use leaves the object alone.
// - Load: cInventoryEntry::ReplaceItemId (0x5d80e0, hooked in skillslots.cpp; // - Load: cInventoryEntry::ReplaceItemId (0x5d80e0, hooked in skillslots.cpp;
// hero files map saved ids to the new objects) moves the count to the new id. // 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 // - Drawing: the count on each stack's cell, after cInventoryEntry::DrawItems
// (0x5dc3f0; cell rects from CellRect 0x5dd530). // (0x5dc3f0; cell rects from CellRect 0x5dd530).
#include "mods/potionstack.h" #include "mods/itemstack.h"
#include <windows.h> #include <windows.h>
@@ -42,7 +45,7 @@
#include "game/ui/text.h" #include "game/ui/text.h"
#include "re/hook.h" #include "re/hook.h"
namespace mods::potionstack { namespace mods::itemstack {
namespace { namespace {
constexpr unsigned kMaxStack = 20; constexpr unsigned kMaxStack = 20;
@@ -62,7 +65,7 @@ struct Stack {
std::mutex g_lock; std::mutex g_lock;
std::unordered_map<uint32_t, Stack> g_stacks; std::unordered_map<uint32_t, Stack> 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 { struct Entry {
uint8_t* inventory; uint8_t* inventory;
uint32_t id; uint32_t id;
@@ -70,7 +73,7 @@ struct Entry {
std::vector<Entry> g_entered; std::vector<Entry> g_entered;
// Inside cInventoryEntry::Sort (its re-placements are no entries). // Inside cInventoryEntry::Sort (its re-placements are no entries).
thread_local bool g_sorting = false; 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). // engine and the UI thread).
thread_local uint32_t g_drunk = 0; thread_local uint32_t g_drunk = 0;
thread_local DWORD g_drunkAt = 0; thread_local DWORD g_drunkAt = 0;
@@ -80,23 +83,31 @@ uint32_t TypeOf(uint32_t id) {
return item ? game::ItemType(item) : 0; 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). // yet (buying from a stack would sell the whole stack for one price).
bool Stacks(const void* inventory) { bool Stacks(const void* inventory) {
return inventory && game::IsPlayerInventory(static_cast<const uint8_t*>(inventory)); return inventory && game::IsPlayerInventory(static_cast<const uint8_t*>(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 InGrid(const uint8_t* inventory, uint32_t id) {
bool found = false; bool found = false;
game::ForEachGridItem(inventory, [&](uint32_t item) { found = found || item == id; }); game::ForEachGridItem(inventory, [&](uint32_t item) { found = found || item == id; });
return found; 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. // that have room (in grid order); an emptied `id` is removed and destroyed.
void Pour(uint8_t* inventory, uint32_t id) { void Pour(uint8_t* inventory, uint32_t id) {
const uint32_t type = TypeOf(id); const uint32_t type = TypeOf(id);
if (!type || !game::ItemPotionKind(type)) { if (!Stackable(type)) {
return; return;
} }
const unsigned had = StackCount(id); const unsigned had = StackCount(id);
@@ -125,17 +136,17 @@ void Pour(uint8_t* inventory, uint32_t id) {
game::DestroyObject(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. // stacks first.
void StackAll(uint8_t* inventory) { void StackAll(uint8_t* inventory) {
std::vector<uint32_t> potions; std::vector<uint32_t> items;
game::ForEachGridItem(inventory, [&](uint32_t id) { game::ForEachGridItem(inventory, [&](uint32_t id) {
if (game::PotionKindOf(id)) { if (StackableItem(id)) {
potions.push_back(id); items.push_back(id);
} }
}); });
for (size_t i = potions.size(); i-- > 0;) { for (size_t i = items.size(); i-- > 0;) {
Pour(inventory, potions[i]); 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() { void ResetStacks() {
std::lock_guard guard(g_lock); std::lock_guard guard(g_lock);
g_stacks.clear(); g_stacks.clear();
g_entered.clear(); g_entered.clear();
} }
} // namespace mods::potionstack } // namespace mods::itemstack
namespace game { namespace game {
using namespace mods::potionstack; using namespace mods::itemstack;
// cInventoryEntry::PlaceItem (0x5d9ec0, ret 0x14): (cell, id, w, h, notify). // 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); 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); 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) { 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); 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); std::lock_guard guard(g_lock);
g_entered.push_back({static_cast<uint8_t*>(self), id}); g_entered.push_back({static_cast<uint8_t*>(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<uint32_t*>(item + kObjectFlags) : nullptr; auto* flags = item ? reinterpret_cast<uint32_t*>(item + kObjectFlags) : nullptr;
const unsigned count = flags && (*flags & kObjectUsedUp) ? StackCount(id) : 1; const unsigned count = flags && (*flags & kObjectUsedUp) ? StackCount(id) : 1;
if (count > 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; *flags &= ~kObjectUsedUp;
SetStackCount(id, count - 1); SetStackCount(id, count - 1);
g_drunk = id; 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); 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); void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode);
RE_HOOK(0x005db670, Inv_Sort); RE_HOOK(0x005db670, Inv_Sort);
void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) { void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) {
@@ -2,9 +2,9 @@
#include <cstdint> #include <cstdint>
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); unsigned StackCount(uint32_t id);
// Sets the stack size of item `id` (1 or less: no stack). Used when loading. // 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. // cInventoryEntry::ReplaceItemId: the stack follows its item to the new id.
void OnItemIdReplaced(uint32_t oldId, uint32_t newId); 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). // Forgets all stacks (a save game is about to be loaded).
void ResetStacks(); void ResetStacks();
} // namespace mods::potionstack } // namespace mods::itemstack
+3 -3
View File
@@ -8,7 +8,7 @@
// cInventoryEntry::GetPotionRef(k) finds a potion of kind k, else the empty // cInventoryEntry::GetPotionRef(k) finds a potion of kind k, else the empty
// control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks // 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 // 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 <cstdint> #include <cstdint>
#include <cwchar> #include <cwchar>
@@ -16,7 +16,7 @@
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/ui/text.h" #include "game/ui/text.h"
#include "mods/potionstack.h" #include "mods/itemstack.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
@@ -42,7 +42,7 @@ void RenderCounts(uint8_t* taskbar, void* renderer) {
game::ForEachGridItem(inventory, [&](uint32_t id) { game::ForEachGridItem(inventory, [&](uint32_t id) {
const int kind = game::PotionKindOf(id); const int kind = game::PotionKindOf(id);
if (kind >= 1 && kind <= game::kPotionKinds) { if (kind >= 1 && kind <= game::kPotionKinds) {
counts[kind] += potionstack::StackCount(id); counts[kind] += itemstack::StackCount(id);
} }
}); });
const int lineHeight = game::FontLineHeight(kCountFont); const int lineHeight = game::FontLineHeight(kCountFont);
+498
View File
@@ -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 <cstdint>
#include <cstring>
#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 <typename T>
T& Field(uint8_t* window, size_t offset) {
return *reinterpret_cast<T*>(window + offset);
}
void* Cursor() {
return reinterpret_cast<void*(__cdecl*)()>(0x006550f0)(); // MouseCursor_Instance (hud.cpp: shifted copy)
}
CarriedItem* Carried() {
void* cursor = Cursor();
return cursor ? *reinterpret_cast<CarriedItem**>(static_cast<uint8_t*>(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<CarriedItem*>(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<SetFn>(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<HitTestFn>((*reinterpret_cast<void***>(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<uint16_t>(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<unsigned>(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<FindFn>(0x005dab60)(inventory, nullptr, 1, 1, cell, 0) != 0;
}
void SetPrices() {
std::memcpy(reinterpret_cast<void*>(kPriceTable), kPrices, sizeof(kPrices));
}
void Select(uint8_t* window, uint32_t mode) {
using SelectFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t);
reinterpret_cast<SelectFn>(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<FlagFn>(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<uint32_t>(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<uint32_t>(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<PayFn>(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<uint32_t>(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<PayFn>(0x006d3ce0)(window, nullptr, 1)) {
return;
}
for (int slot = 0; slot < kSlots; ++slot) {
auto* descriptor = &Field<CarriedItem>(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<CreateFn>(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<PlaceFn>(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<RefreshFn>(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<PlaceFn>(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<EventCall*>(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<uint32_t>() : 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<uint32_t>(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<uint8_t*>(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<uint8_t*>(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<uint32_t>(g_window, kBuyTextColour) = *reinterpret_cast<const uint32_t*>(kDisabledTextColour);
return reinterpret_cast<const void*>(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<const wchar_t*(__fastcall*)(void*, void*)>(0x0044a420)(item, nullptr);
}
void DrawChosenName(uint8_t* window, void* renderer) {
const uint32_t object = Field<uint32_t>(window, kChosenObject);
void* item = object && Field<uint32_t>(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<int16_t>(kNameWidth),
static_cast<int16_t>(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<RenderCall*>(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<uint8_t*>(self), renderer, UiMasterLotto_Render_original};
return mods::hud::RenderCentred(self, &mods::runetrade::RenderBody, &call);
}
} // namespace game
+410
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
#include <windows.h>
#include <nlohmann/json.hpp>
#include "game/engine/events.h"
#include "game/engine/items.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/stream.h"
#include "game/ui/tabs.h"
#include "game/ui/text.h"
#include "game/ui/texts.h"
#include "mods/itemstack.h"
#include "mods/sharedchest.h"
#include "mods/skillslots.h"
#include "re/config.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::sharedchest {
namespace {
using nlohmann::json;
constexpr int kFileVersion = 1;
// cUI_Chest
constexpr size_t kWindowInventory = 0x158;
constexpr uint32_t kControlVisible = 1; // +0x10 bit 0
constexpr size_t kControlFlags = 0x10;
// cInventoryEntry: the grid origin on screen and the item's size in its first cell.
constexpr size_t kGridOriginX = 0x1164;
constexpr size_t kGridOriginY = 0x1166;
constexpr size_t kCellWidth = 5; // u8 in the cell, as PlaceItem stores it
constexpr size_t kCellHeight = 6; // u8
constexpr int kCellPixels = 32;
alignas(8) uint8_t g_storage[skillslots::kInventorySize];
bool g_constructed = false;
// The shared chest of this session came from the file (only then is it saved).
bool g_loaded = false;
bool g_loadPending = false;
void* g_chestWindow = nullptr;
void* g_sheet = nullptr; // our cUI_TabSheet in it
uint8_t* GameInventory(uint32_t index) {
return skillslots::Inventory(index);
}
uint8_t* Shared() {
if (!g_constructed) {
using ConstructFn = void(__fastcall*)(void*, void*, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t);
reinterpret_cast<ConstructFn>(0x005d7a10)(g_storage, nullptr, 0, 0, 0x14, 0xa, 0); // cInventoryEntry::Construct
*reinterpret_cast<uint16_t*>(g_storage + game::kInventoryOwner) = game::kChestInventory;
g_constructed = true;
}
return g_storage;
}
void ClearGrid() {
std::memset(Shared() + game::kInventoryGrid, 0, game::GridCells(Shared()) * game::kGridCellSize);
}
bool SharedShown() {
return g_chestWindow &&
*reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(g_chestWindow) + kWindowInventory) == Shared();
}
bool Visible(void* window) {
return (*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(window) + kControlFlags) & kControlVisible) != 0;
}
// The chest window is open on the shared tab.
bool SharedOpen() {
return SharedShown() && Visible(g_chestWindow);
}
std::string FilePath() {
std::string dir = re::ConfigString("paths", "GameDir", "");
if (!dir.empty() && dir.back() != '\\' && dir.back() != '/') {
dir += '\\';
}
return dir + "save\\shared_chest.json";
}
// --- items <-> JSON ------------------------------------------------------------
json ItemJson(const uint8_t* inventory, unsigned cell, uint32_t id) {
void* item = game::GetObjectById(id);
if (!item) {
return nullptr;
}
const std::vector<uint8_t> data = game::SerializeItem(item);
const uint8_t* entry = inventory + game::kInventoryGrid + cell * game::kGridCellSize;
json j = {{"cell", cell},
{"w", entry[kCellWidth]},
{"h", entry[kCellHeight]},
{"type", game::ItemType(item)},
{"data", game::Hex(data.data(), data.size())}};
if (const unsigned count = itemstack::StackCount(id); count > 1) {
j["count"] = count;
}
return j;
}
// Recreates an item as loadHero does; returns its new id, 0 on failure.
uint32_t CreateItem(const json& j, uint32_t heroId) {
const std::string hex = j.at("data");
std::vector<uint8_t> data(hex.size() / 2);
game::Unhex(hex, data.data(), data.size());
return game::CreateItem(j.at("type"), data, heroId);
}
void Load() {
ClearGrid();
std::ifstream file(FilePath(), std::ios::binary);
if (!file) {
re::Log("shared chest: no %s yet", FilePath().c_str());
return;
}
void* hero = game::GetPlayerHero();
const auto heroId = hero ? static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId)) : 0u;
int loaded = 0;
try {
const json j = json::parse(file);
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
for (const json& entry : j.at("items")) {
const uint32_t id = CreateItem(entry, heroId);
if (!id) {
re::Log("shared chest: item of type %#x not recreated", entry.at("type").get<uint32_t>());
continue;
}
reinterpret_cast<PlaceFn>(0x005d9ec0)(Shared(), nullptr, entry.at("cell"), id, entry.at("w"),
entry.at("h"), 0); // cInventoryEntry::PlaceItem
if (entry.contains("count")) {
itemstack::SetStackCount(id, entry.at("count"));
}
++loaded;
}
} catch (const std::exception& e) {
re::Log("shared chest: %s unreadable (%s)", FilePath().c_str(), e.what());
}
re::Log("shared chest: %d items loaded", loaded);
}
// A session started or a save game was loaded into it (its objects replace
// the old ones): (re)load on the next tick with a hero.
void RequestLoad() {
g_loadPending = true;
}
// The chest grid's place and size, as the cUI_Chest constructor sets them for
// inventory 0x11: (window x, 20), 20 x 10. Loads overwrite the place of 0x11
// (the game's inventory read resets some to 0, the JSON inventories restore the
// saved one), so both tabs are put back every tick.
constexpr size_t kWindowX = 0x24; // i16
constexpr uint16_t kGridY = 20;
constexpr uint16_t kChestColumns = 0x14;
constexpr uint16_t kChestRows = 0xa;
void PlaceGrid(void* window, uint8_t* inventory) {
*reinterpret_cast<uint16_t*>(inventory + kGridOriginX) =
*reinterpret_cast<const uint16_t*>(static_cast<uint8_t*>(window) + kWindowX);
*reinterpret_cast<uint16_t*>(inventory + kGridOriginY) = kGridY;
*reinterpret_cast<uint16_t*>(inventory + game::kGridWidth) = kChestColumns;
*reinterpret_cast<uint16_t*>(inventory + game::kGridHeight) = kChestRows;
}
void PlaceGrids() {
if (g_chestWindow) {
PlaceGrid(g_chestWindow, GameInventory(game::kChestInventory));
PlaceGrid(g_chestWindow, Shared());
}
}
void OnTick(uint8_t*) {
if (g_loadPending && game::GetPlayerHero()) {
g_loadPending = false;
g_loaded = true;
Load();
}
PlaceGrids();
}
void OnGameEnd() {
if (g_constructed) {
ClearGrid();
}
g_loaded = false;
g_loadPending = false;
g_chestWindow = nullptr; // set again when the next session builds its chest window
g_sheet = nullptr;
}
// The inventory window's tab headers (Rucksack / Kampfkünste / Combos, AddTab at
// 0x6c63ab..0x6c63d5) are upper case in the text resource; capitalized like the
// chest's (request 2026-10-17).
constexpr uint32_t kInventoryTabTexts[] = {0x483, 0x484, 0x485};
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnGameStart(&RequestLoad);
game::OnSaveGameLoaded(&RequestLoad);
game::OnGameEnd(&OnGameEnd);
for (const uint32_t id : kInventoryTabTexts) {
game::CapitalizeText(id);
}
}
} g_registration;
// --- tabs ----------------------------------------------------------------------
// A cUI_TabSheet of the game's own (the inventory's header look) in the chest
// window, its headers stacked in a column left of the grid's top-left corner.
constexpr int kHeaderGap = 2;
void ShowTab(void* window, bool shared) {
uint8_t* chest = GameInventory(game::kChestInventory);
PlaceGrids();
*reinterpret_cast<uint8_t**>(static_cast<uint8_t*>(window) + kWindowInventory) = shared ? Shared() : chest;
}
// The headers as a column: same width, right edge at the sheet's left edge.
void LayOutColumn(void* sheet) {
uint8_t* control = game::TabControl(sheet);
if (!control) {
return;
}
uint8_t* entries = game::TabEntries(control);
const size_t count = game::TabCount(control);
int width = 0;
int height = *reinterpret_cast<const int16_t*>(game::kTabHeaderArtHeight);
for (size_t i = 0; i < count; ++i) {
const auto* rect = reinterpret_cast<game::UiRect*>(entries + i * game::kTabEntrySize + game::kTabEntryRect);
width = std::max<int>(width, rect->w);
height = std::max<int>(height, rect->h);
}
for (size_t i = 0; i < count; ++i) {
uint8_t* entry = entries + i * game::kTabEntrySize;
auto* rect = reinterpret_cast<game::UiRect*>(entry + game::kTabEntryRect);
rect->x = -width - kHeaderGap;
rect->y = static_cast<int32_t>(i) * (height + kHeaderGap);
rect->w = static_cast<int16_t>(width);
if (auto* header = *reinterpret_cast<uint8_t**>(entry + game::kTabEntryHeader)) {
*reinterpret_cast<game::UiRect*>(header + game::kControlRect) = *rect;
}
}
}
// Built right after the chest window (cUI_Chest constructor).
void CreateTabs(void* window) {
static const uint32_t kNames[2] = {game::RegisterText(L"Truhe"), game::RegisterText(L"Gemeinsam")};
g_chestWindow = window;
g_sheet = game::NewTabSheet("UI_TABSHEET_CHEST", {0, kGridY, 0, 0}, window);
if (!g_sheet) {
return;
}
// Pages: empty controls (the grid stays the chest's own; the tab only
// switches the inventory behind it).
static const char* const kPageNames[2] = {"UI_TABSHEET_CHEST_HERO", "UI_TABSHEET_CHEST_SHARED"};
for (int tab = 0; tab < 2; ++tab) {
game::AddTab(g_sheet, kNames[tab], game::NewTabSheet(kPageNames[tab], {0, 0, 0, 0}, g_sheet));
}
game::SetActiveTab(g_sheet, 0);
game::AddWindowControl(window, g_sheet);
}
// Moves `id` from `from` into free space in `to` (as MoveItem does).
bool MoveBetween(uint8_t* from, uint8_t* to, uint32_t id) {
const unsigned cells = game::GridCells(from);
for (unsigned cell = 0; cell < cells; ++cell) {
if (game::GridItem(from, cell) != id) {
continue;
}
const uint8_t* entry = from + game::kInventoryGrid + cell * game::kGridCellSize;
const uint32_t w = entry[kCellWidth];
const uint32_t h = entry[kCellHeight];
uint16_t target = 0;
using FindFn = char(__fastcall*)(void*, void*, uint32_t, uint32_t, uint16_t*, uint32_t);
if (!reinterpret_cast<FindFn>(0x005dab60)(to, nullptr, w, h, &target, 0)) { // FindFreeSpace
return false;
}
using RemoveFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t);
using PlaceFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t);
reinterpret_cast<RemoveFn>(0x005da5f0)(from, nullptr, id, 1); // RemoveItem
reinterpret_cast<PlaceFn>(0x005d9ec0)(to, nullptr, target, id, w, h, 1); // PlaceItem
return true;
}
return false;
}
} // namespace
// Called after the game saved a hero (inventory_save.cpp, ItemDataMgr::saveHero).
void Save() {
if (!g_loaded || !g_constructed) {
return;
}
json items = json::array();
const uint8_t* shared = Shared();
const unsigned cells = game::GridCells(shared);
for (unsigned cell = 0; cell < cells; ++cell) {
if (const uint32_t id = game::GridItem(shared, cell)) {
json item = ItemJson(shared, cell, id);
if (!item.is_null()) {
items.push_back(std::move(item));
}
}
}
const json j = {{"format", "sacred_re.shared_chest"}, {"version", kFileVersion}, {"items", items}};
const std::string path = FilePath();
const std::string temp = path + ".tmp";
{
std::ofstream file(temp, std::ios::binary | std::ios::trunc);
file << j.dump(1);
if (!file) {
re::Log("shared chest: writing %s failed", temp.c_str());
return;
}
}
if (!MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING)) {
re::Log("shared chest: replacing %s failed (%lu)", path.c_str(), GetLastError());
return;
}
re::Log("shared chest: %u items saved", static_cast<unsigned>(items.size()));
}
} // namespace mods::sharedchest
namespace game {
using namespace mods::sharedchest;
// cUI_Chest::cUI_Chest (0x6ac7f0, ret 0x18): (name, rect, parent, flags); then
// the tabs.
void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
uint32_t flags);
RE_HOOK(0x006ac7f0, UiChest_Construct);
void* __fastcall UiChest_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent,
uint32_t flags) {
void* window = UiChest_Construct_original(self, edx, name, rect, parent, flags);
if (window) {
CreateTabs(window);
re::Log("shared chest: tabs created");
}
return window;
}
// cUI_TabSheet layout (0x729210, plain ret): headers in a row; ours in a column.
void __fastcall TabSheet_Layout(void* self, void* edx);
RE_HOOK(0x00729210, TabSheet_Layout);
void __fastcall TabSheet_Layout(void* self, void* edx) {
TabSheet_Layout_original(self, edx);
if (self && self == g_sheet) {
LayOutColumn(self);
}
}
// cUI_TabControl::SetActive (0x728af0, ret 4): our tabs switch the chest's inventory.
void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index);
RE_HOOK(0x00728af0, TabControl_SetActive);
void __fastcall TabControl_SetActive(void* self, void* edx, uint32_t index) {
TabControl_SetActive_original(self, edx, index);
if (g_sheet && g_chestWindow && self == TabControl(g_sheet)) {
ShowTab(g_chestWindow, (index & 0xffff) == 1);
}
}
// cInventoryManager::MoveItem (0x5dec80, ret 0x10): (from, to, id, ?). Shift+click
// in the chest moves from 0x11; with the shared tab shown, from the shared chest.
char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net);
RE_HOOK(0x005dec80, InvManager_MoveItem);
char __fastcall InvManager_MoveItem(void* self, void* edx, uint32_t from, uint32_t to, uint32_t id, uint32_t net) {
if (SharedOpen() && (from & 0xffff) == kChestInventory && (to & 0xffff) < 0x20) {
return MoveBetween(Shared(), GameInventory(to & 0xffff), id) ? 1 : 0;
}
if (SharedOpen() && (to & 0xffff) == kChestInventory && (from & 0xffff) < 0x20) {
return MoveBetween(GameInventory(from & 0xffff), Shared(), id) ? 1 : 0;
}
return InvManager_MoveItem_original(self, edx, from, to, id, net);
}
} // namespace game
+8
View File
@@ -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
+2 -2
View File
@@ -13,7 +13,7 @@
// Network events sent from the shadow would name slot - 5: multiplayer with // Network events sent from the shadow would name slot - 5: multiplayer with
// more than 5 slots is not supported. // more than 5 slots is not supported.
#include "mods/potionstack.h" #include "mods/itemstack.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include <cstring> #include <cstring>
@@ -203,7 +203,7 @@ void __fastcall Inv_ReplaceItemId(void* self, void* edx, int oldId, int newId);
RE_HOOK(0x005d80e0, Inv_ReplaceItemId); RE_HOOK(0x005d80e0, Inv_ReplaceItemId);
void __fastcall Inv_ReplaceItemId(void* self, void*, int oldId, int newId) { void __fastcall Inv_ReplaceItemId(void* self, void*, int oldId, int newId) {
WithShadow(Inv_ReplaceItemId_original, self, oldId, newId); WithShadow(Inv_ReplaceItemId_original, self, oldId, newId);
mods::potionstack::OnItemIdReplaced(static_cast<uint32_t>(oldId), static_cast<uint32_t>(newId)); mods::itemstack::OnItemIdReplaced(static_cast<uint32_t>(oldId), static_cast<uint32_t>(newId));
} }
void __fastcall Inv_FixInventory(void* self, void* edx); void __fastcall Inv_FixInventory(void* self, void* edx);
+41 -1
View File
@@ -163,7 +163,7 @@
0x00539ce0 cCreature::StartArtCooldown # AddEffect(0x22, ...) 0x00539ce0 cCreature::StartArtCooldown # AddEffect(0x22, ...)
0x004feef0 cCreature::ArtCastEffect # per-art effect of using an art (switch on art id) 0x004feef0 cCreature::ArtCastEffect # per-art effect of using an art (switch on art id)
0x005fbaa0 cObjectManager::CreateAt # (type, position, ...): object placed in the world 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 0x005fb040 cObjectManager::Allocate
0x004514d0 ObjectFactory::Create # (class, ...) 0x004514d0 ObjectFactory::Create # (class, ...)
0x005fcd10 cObjectManager::Place # (id, position); refuses dead objects (+0x14 & 0x10) 0x005fcd10 cObjectManager::Place # (id, position); refuses dead objects (+0x14 & 0x10)
@@ -205,3 +205,43 @@
0x00580fe0 cCreatureArts::ChooseSkill # (skill id) 0x00580fe0 cCreatureArts::ChooseSkill # (skill id)
0x00579560 cCreatureArts::Recalculate # (0, 1) after point changes 0x00579560 cCreatureArts::Recalculate # (0, 1) after point changes
0x0072af90 cUI_Button::Render 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