Files
sacred_k/src/mods/itemstack.cpp
T
khallaandClaude Opus 5.5 46a08e97e9 Teleportationstrank: yellow potions open the portal window
- 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>
2026-10-17 04:04:21 +02:00

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