- Every yellow potion (potion kind 5, all stages) is a Teleportationstrank: drinking it posts the teleporter's event to the busy dialog with the hero's known portals; the potion is used up only when the hero teleports from that window (closing it keeps the potion) - Names replaced in every game text (ReplaceInTexts), so item names and the bottom bar's quick-select tooltip read Teleportationstrank; GameText reads the game's own text - HeroIndex / HeroInventory moved to skillslots for shared use - docs/teleport-potion.md; walking animation after loading noted in todo Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
348 lines
12 KiB
C++
348 lines
12 KiB
C++
// 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 <windows.h>
|
|
|
|
#include <cwchar>
|
|
#include <mutex>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#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<uint32_t, Stack> g_stacks;
|
|
|
|
// Stackable items placed into a grid, poured on the next tick.
|
|
struct Entry {
|
|
uint8_t* inventory;
|
|
uint32_t id;
|
|
};
|
|
std::vector<Entry> 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<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 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<RemoveItemFn>(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<uint32_t> 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<Entry> 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<const uint8_t*>(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<CellRectFn>(0x005dd530)(inventory, nullptr, &rect, cell); // cInventoryEntry::CellRect
|
|
rect.y += rect.h - lineHeight - kCountInset;
|
|
rect.w = static_cast<int16_t>(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<uint8_t*>(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<uint8_t*>(id ? GetObjectById(id) : nullptr);
|
|
auto* flags = item ? reinterpret_cast<uint32_t*>(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<uint8_t*>(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
|