Potion stacks in every player inventory and the chest, not in trader stock

The chest is inventory 0x11 and already saved as JSON; stacking now applies
to all inventories below 0x12. Traders keep selling single potions.

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 01:37:48 +02:00
1 parent 13b91cdd3d
commit 3d9d864c25
3 files changed
+43 -27

No files matched your search

+10
View File
@@ -57,3 +57,13 @@ Not seen yet: a sale, a right-click drink.
- Dropping a stack on the ground: it should split into single potions; for now - Dropping a stack on the ground: it should split into single potions; for now
the count stays with the object while the game runs, but a save keeps only the count stays with the object while the game runs, but a save keeps only
the object (one potion). the object (one potion).
## Chest and other inventories (2026-10-17)
Asked: potions stack in every kind of inventory; traders only sell single
potions. Measured: the chest is inventory 0x11 (`cUI_Chest` draws window
+0x158), saved with the hero as chunk 0xc6 and in save games, both through the
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)
never stacks. Selling a stack to a trader still has to be handled (pay
count x price, the trader keeps singles).
+10 -1
View File
@@ -42,7 +42,16 @@ constexpr size_t kInventoryGrid = 0x18;
constexpr size_t kGridCellSize = 0xc; constexpr size_t kGridCellSize = 0xc;
constexpr size_t kGridWidth = 0x115c; // u16, 0x14 constexpr size_t kGridWidth = 0x115c; // u16, 0x14
constexpr size_t kGridHeight = 0x115e; // u16, 8 constexpr size_t kGridHeight = 0x115e; // u16, 8
constexpr size_t kInventoryOwner = 0x1160; // u16: player index (heroes < 0x12) constexpr size_t kInventoryOwner = 0x1160; // u16: = the inventory's index (measured)
// Inventories below 0x12 belong to the players (index = hero id) and the
// chest (0x11, saved with the hero as chunk 0xc6); PlaceItem / RemoveAt only
// update the hero below that. Traders use higher ones (22 measured).
constexpr uint16_t kPlayerInventoryEnd = 0x12;
constexpr uint16_t kChestInventory = 0x11;
inline bool IsPlayerInventory(const uint8_t* inventory) {
return *reinterpret_cast<const uint16_t*>(inventory + kInventoryOwner) < kPlayerInventoryEnd;
}
inline unsigned GridCells(const uint8_t* inventory) { inline unsigned GridCells(const uint8_t* inventory) {
return *reinterpret_cast<const uint16_t*>(inventory + kGridWidth) * return *reinterpret_cast<const uint16_t*>(inventory + kGridWidth) *
+23 -26
View File
@@ -5,8 +5,11 @@
// id, with the item type to tell a reused id apart) and saved with the JSON // id, with the item type to tell a reused id apart) and saved with the JSON
// inventories (src/mods/inventory_save.cpp). // inventories (src/mods/inventory_save.cpp).
// //
// - Entry: a potion placed into the hero's grid (picked up, bought, dropped // Stacks exist in the players' inventories and the chest (inventory 0x11);
// from the cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other // trader stock waits for stack prices.
//
// - Entry: a potion 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 // 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.
@@ -36,9 +39,7 @@
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/items.h" #include "game/engine/items.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/ui/text.h" #include "game/ui/text.h"
#include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
namespace mods::potionstack { namespace mods::potionstack {
@@ -61,8 +62,12 @@ 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 the hero's grid, poured on the next tick. // Potions placed into a grid, poured on the next tick.
std::vector<uint32_t> g_entered; struct Entry {
uint8_t* inventory;
uint32_t id;
};
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 drink just took a potion from (per thread: drinks come from the
@@ -75,14 +80,10 @@ uint32_t TypeOf(uint32_t id) {
return item ? game::ItemType(item) : 0; return item ? game::ItemType(item) : 0;
} }
uint8_t* HeroInventory() { // Potions stack in the players' inventories and the chest; trader stock not
void* hero = game::GetPlayerHero(); // yet (buying from a stack would sell the whole stack for one price).
return hero ? skillslots::Inventory(static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId))) bool Stacks(const void* inventory) {
: nullptr; return inventory && game::IsPlayerInventory(static_cast<const uint8_t*>(inventory));
}
bool IsHeroInventory(const void* inventory) {
return inventory && inventory == HeroInventory();
} }
bool InGrid(const uint8_t* inventory, uint32_t id) { bool InGrid(const uint8_t* inventory, uint32_t id) {
@@ -139,18 +140,14 @@ void StackAll(uint8_t* inventory) {
} }
void OnTick(uint8_t*) { void OnTick(uint8_t*) {
std::vector<uint32_t> entered; std::vector<Entry> entered;
{ {
std::lock_guard guard(g_lock); std::lock_guard guard(g_lock);
entered.swap(g_entered); entered.swap(g_entered);
} }
uint8_t* inventory = entered.empty() ? nullptr : HeroInventory(); for (const Entry& entry : entered) {
if (!inventory) { if (InGrid(entry.inventory, entry.id)) {
return; Pour(entry.inventory, entry.id);
}
for (const uint32_t id : entered) {
if (InGrid(inventory, id)) {
Pour(inventory, id);
} }
} }
} }
@@ -249,9 +246,9 @@ int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id,
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) && IsHeroInventory(self)) { if (!g_sorting && PotionKindOf(id) && Stacks(self)) {
std::lock_guard guard(g_lock); std::lock_guard guard(g_lock);
g_entered.push_back(id); g_entered.push_back({static_cast<uint8_t*>(self), id});
} }
return previous; return previous;
} }
@@ -296,11 +293,11 @@ 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 hero's potions first. // cInventoryEntry::Sort (0x5db670, ret 4): stack the potions 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) {
if (IsHeroInventory(self)) { if (Stacks(self)) {
StackAll(static_cast<uint8_t*>(self)); StackAll(static_cast<uint8_t*>(self));
} }
g_sorting = true; g_sorting = true;