// Item stacks: potions and combat-art runes (requests 2026-10-17, // docs/item-stacks.md). // // Items of the same type that the game counts as consumable (potions, runes; // ItemIsConsumable 0x43f5f0) share one inventory cell, up to kMaxStack. Such // items differ only in their object state (measured for runes), so the type // alone decides. A stack is the one object in the cell plus a count kept here // (by item id, with the item type to tell a reused id apart) and saved with // the JSON inventories (src/mods/inventory_save.cpp). // // Stacks exist in the players' inventories and the chest (inventory 0x11); // trader stock waits for stack prices. // // - Entry: an item placed into a grid (picked up, bought, dropped from the // cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other // stacks of its type that have room, on the next engine tick (the caller // still uses the object while placing it). An emptied object is removed and // destroyed. // - Sort button (cInventoryEntry::Sort 0x5db670, the inventory window's // button): all items of a type are poured together first. // - Use (cCreature::UseItem 0x54a8f0, measured for potions; runes, class 0x1c, // take the same path): the item's event handler (vf18) applies the effect // and marks it used up; UseItem then removes it from the grid (RemoveItem // 0x5da5f0) and destroys it (cObjectManager::DestroyObject 0x5fbe10), and // cEngine::UseActionSlot removes it once more. For a stack of more than // one, the first removal takes one off the count instead, and the rest of // that use leaves the object alone. // - Load: cInventoryEntry::ReplaceItemId (0x5d80e0, hooked in skillslots.cpp; // hero files map saved ids to the new objects) moves the count to the new id. // - Drawing: the count on each stack's cell, after cInventoryEntry::DrawItems // (0x5dc3f0; cell rects from CellRect 0x5dd530). #include "mods/itemstack.h" #include "mods/teleportpotion.h" #include #include #include #include #include #include "game/engine/events.h" #include "game/engine/items.h" #include "game/engine/objects.h" #include "game/ui/text.h" #include "re/hook.h" namespace mods::itemstack { namespace { constexpr unsigned kMaxStack = 20; constexpr uint16_t kCountFont = 1; // as the potion slot counts (potioncounts.cpp) constexpr uint32_t kCountColor = 0xffffffff; constexpr int kCountInset = 2; // How long a drink's later removals of its stack are ignored. constexpr DWORD kDrinkMs = 100; struct Stack { unsigned count; uint32_t type; // 0: not known yet (set on load before the objects exist) }; std::mutex g_lock; std::unordered_map g_stacks; // Stackable items placed into a grid, poured on the next tick. struct Entry { uint8_t* inventory; uint32_t id; }; std::vector g_entered; // Inside cInventoryEntry::Sort (its re-placements are no entries). thread_local bool g_sorting = false; // The stack a use just took one from (per thread: uses come from the // engine and the UI thread). thread_local uint32_t g_drunk = 0; thread_local DWORD g_drunkAt = 0; uint32_t TypeOf(uint32_t id) { const void* item = id ? game::GetObjectById(id) : nullptr; return item ? game::ItemType(item) : 0; } // Stacks exist in the players' inventories and the chest; trader stock not // yet (buying from a stack would sell the whole stack for one price). bool Stacks(const void* inventory) { return inventory && game::IsPlayerInventory(static_cast(inventory)); } bool Stackable(uint32_t type) { return type && game::ItemIsConsumable(type); } bool StackableItem(uint32_t id) { return Stackable(TypeOf(id)); } bool InGrid(const uint8_t* inventory, uint32_t id) { bool found = false; game::ForEachGridItem(inventory, [&](uint32_t item) { found = found || item == id; }); return found; } // Moves the items of `id` into the other stacks of its type in `inventory` // that have room (in grid order); an emptied `id` is removed and destroyed. void Pour(uint8_t* inventory, uint32_t id) { const uint32_t type = TypeOf(id); if (!Stackable(type)) { return; } const unsigned had = StackCount(id); unsigned left = had; game::ForEachGridItem(inventory, [&](uint32_t other) { if (left == 0 || other == id || TypeOf(other) != type) { return; } const unsigned count = StackCount(other); const unsigned room = count < kMaxStack ? kMaxStack - count : 0; const unsigned moved = left < room ? left : room; if (moved) { SetStackCount(other, count + moved); left -= moved; } }); if (left == had) { return; } if (left > 0) { SetStackCount(id, left); return; } using RemoveItemFn = int(__fastcall*)(void*, void*, uint32_t, uint32_t); reinterpret_cast(0x005da5f0)(inventory, nullptr, id, 1); // cInventoryEntry::RemoveItem game::DestroyObject(id); } // Pours the stacks from the back of the grid into those before them: full // stacks first. void StackAll(uint8_t* inventory) { std::vector items; game::ForEachGridItem(inventory, [&](uint32_t id) { if (StackableItem(id)) { items.push_back(id); } }); for (size_t i = items.size(); i-- > 0;) { Pour(inventory, items[i]); } } void OnTick(uint8_t*) { std::vector entered; { std::lock_guard guard(g_lock); entered.swap(g_entered); } for (const Entry& entry : entered) { if (InGrid(entry.inventory, entry.id)) { Pour(entry.inventory, entry.id); } } } struct Registration { Registration() { game::OnEngineTick(&OnTick); } } g_registration; void DrawCounts(void* inventory, void* renderer) { auto* grid = static_cast(inventory); const int lineHeight = game::FontLineHeight(kCountFont); const unsigned cells = game::GridCells(grid); for (unsigned cell = 0; cell < cells; ++cell) { const unsigned count = StackCount(game::GridItem(grid, cell)); if (count <= 1) { continue; } using CellRectFn = game::UiRect*(__fastcall*)(void*, void*, game::UiRect*, uint32_t); game::UiRect rect = {}; reinterpret_cast(0x005dd530)(inventory, nullptr, &rect, cell); // cInventoryEntry::CellRect rect.y += rect.h - lineHeight - kCountInset; rect.w = static_cast(rect.w - kCountInset); wchar_t text[8]; swprintf_s(text, L"%u", count); game::DrawText(renderer, kCountFont, rect, text, kCountColor, game::kTextRight); } } bool DrinkInProgress(uint32_t id) { return id && id == g_drunk && GetTickCount() - g_drunkAt < kDrinkMs; } void Forget(uint32_t id) { std::lock_guard guard(g_lock); g_stacks.erase(id); } } // namespace unsigned StackCount(uint32_t id) { if (!id) { return 1; } Stack stack; { std::lock_guard guard(g_lock); const auto it = g_stacks.find(id); if (it == g_stacks.end()) { return 1; } stack = it->second; } const uint32_t type = TypeOf(id); if (stack.type == 0 && type != 0) { std::lock_guard guard(g_lock); g_stacks[id].type = type; // first seen after loading return stack.count; } return type == stack.type ? stack.count : 1; } void SetStackCount(uint32_t id, unsigned count) { if (count <= 1) { Forget(id); return; } const uint32_t type = TypeOf(id); std::lock_guard guard(g_lock); g_stacks[id] = {count, type}; } void OnItemIdReplaced(uint32_t oldId, uint32_t newId) { std::lock_guard guard(g_lock); const auto it = g_stacks.find(oldId); if (it != g_stacks.end() && oldId != newId) { const unsigned count = it->second.count; g_stacks.erase(it); g_stacks[newId] = {count, 0}; // type taken when first seen } } uint32_t StackWithRoom(const uint8_t* inventory, uint32_t type) { uint32_t found = 0; if (inventory && Stacks(inventory) && Stackable(type)) { game::ForEachGridItem(inventory, [&](uint32_t id) { if (!found && TypeOf(id) == type && StackCount(id) < kMaxStack) { found = id; } }); } return found; } void ResetStacks() { std::lock_guard guard(g_lock); g_stacks.clear(); g_entered.clear(); } } // namespace mods::itemstack namespace game { using namespace mods::itemstack; // cInventoryEntry::PlaceItem (0x5d9ec0, ret 0x14): (cell, id, w, h, notify). int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify); RE_HOOK(0x005d9ec0, Inv_PlaceItem); int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id, uint32_t w, uint32_t h, uint32_t notify) { const int previous = Inv_PlaceItem_original(self, edx, cell, id, w, h, notify); if (!g_sorting && StackableItem(id) && Stacks(self)) { std::lock_guard guard(g_lock); g_entered.push_back({static_cast(self), id}); } return previous; } // cInventoryEntry::RemoveItem (0x5da5f0, ret 8): (id, notify). int __fastcall Inv_RemoveItem(void* self, void* edx, uint32_t id, uint32_t notify); RE_HOOK(0x005da5f0, Inv_RemoveItem); int __fastcall Inv_RemoveItem(void* self, void* edx, uint32_t id, uint32_t notify) { if (DrinkInProgress(id)) { return 1; } auto* item = static_cast(id ? GetObjectById(id) : nullptr); auto* flags = item ? reinterpret_cast(item + kObjectFlags) : nullptr; const unsigned count = flags && (*flags & kObjectUsedUp) ? StackCount(id) : 1; if (count > 1) { // A use from a stack: one less, the object stays. *flags &= ~kObjectUsedUp; SetStackCount(id, count - 1); g_drunk = id; g_drunkAt = GetTickCount(); return 1; } return Inv_RemoveItem_original(self, edx, id, notify); } // cObjectManager::DestroyObject (0x5fbe10, ret 0x10): (id, 1, 0, 0) from UseItem. uint32_t __fastcall ObjMgr_DestroyObject(void* self, void* edx, uint32_t id, uint32_t a, uint32_t b, uint32_t c); RE_HOOK(0x005fbe10, ObjMgr_DestroyObject); uint32_t __fastcall ObjMgr_DestroyObject(void* self, void* edx, uint32_t id, uint32_t a, uint32_t b, uint32_t c) { if (DrinkInProgress(id)) { return 0; } Forget(id); return ObjMgr_DestroyObject_original(self, edx, id, a, b, c); } // cCreature::UseItem (0x54a8f0, ret 4): a new drink starts (a // Teleportationstrank opens the portal window instead, teleportpotion.cpp). char __fastcall Creature_UseItem(void* self, void* edx, uint32_t id); RE_HOOK(0x0054a8f0, Creature_UseItem); char __fastcall Creature_UseItem(void* self, void* edx, uint32_t id) { g_drunk = 0; if (mods::teleportpotion::UseItem(self, id)) { return 0; } return Creature_UseItem_original(self, edx, id); } // cInventoryEntry::Sort (0x5db670, ret 4): stack first. void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode); RE_HOOK(0x005db670, Inv_Sort); void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) { if (Stacks(self)) { StackAll(static_cast(self)); } g_sorting = true; Inv_Sort_original(self, edx, mode); g_sorting = false; } // cInventoryEntry::DrawItems (0x5dc3f0, ret 8): (renderer, flag); then the counts. void __fastcall Inv_DrawItems(void* self, void* edx, void* renderer, uint32_t flag); RE_HOOK(0x005dc3f0, Inv_DrawItems); void __fastcall Inv_DrawItems(void* self, void* edx, void* renderer, uint32_t flag) { Inv_DrawItems_original(self, edx, renderer, flag); if (self) { DrawCounts(self, renderer); } } } // namespace game