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:
1 parent
3d9d864c25
commit
6ca96d54b4
29 files changed
+2006
-171
No files matched your search
@@ -0,0 +1,341 @@
|
||||
// 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 <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.
|
||||
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;
|
||||
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
|
||||
Reference in new issue
Block a user