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
the count stays with the object while the game runs, but a save keeps only
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 kGridWidth = 0x115c; // u16, 0x14
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) {
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
// inventories (src/mods/inventory_save.cpp).
//
// - Entry: a potion placed into the hero's grid (picked up, bought, dropped
// from the cursor; cInventoryEntry::PlaceItem 0x5d9ec0) pours into other
// Stacks exist in the players' inventories and the chest (inventory 0x11);
// 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
// still uses the object while placing it). An emptied object is removed and
// destroyed.
@@ -36,9 +39,7 @@
#include "game/engine/events.h"
#include "game/engine/items.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/ui/text.h"
#include "mods/skillslots.h"
#include "re/hook.h"
namespace mods::potionstack {
@@ -61,8 +62,12 @@ struct Stack {
std::mutex g_lock;
std::unordered_map<uint32_t, Stack> g_stacks;
// Potions placed into the hero's grid, poured on the next tick.
std::vector<uint32_t> g_entered;
// Potions 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 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;
}
uint8_t* HeroInventory() {
void* hero = game::GetPlayerHero();
return hero ? skillslots::Inventory(static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId)))
: nullptr;
}
bool IsHeroInventory(const void* inventory) {
return inventory && inventory == HeroInventory();
// Potions stack 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 InGrid(const uint8_t* inventory, uint32_t id) {
@@ -139,18 +140,14 @@ void StackAll(uint8_t* inventory) {
}
void OnTick(uint8_t*) {
std::vector<uint32_t> entered;
std::vector<Entry> entered;
{
std::lock_guard guard(g_lock);
entered.swap(g_entered);
}
uint8_t* inventory = entered.empty() ? nullptr : HeroInventory();
if (!inventory) {
return;
}
for (const uint32_t id : entered) {
if (InGrid(inventory, id)) {
Pour(inventory, id);
for (const Entry& entry : entered) {
if (InGrid(entry.inventory, entry.id)) {
Pour(entry.inventory, entry.id);
}
}
}
@@ -249,9 +246,9 @@ int __fastcall Inv_PlaceItem(void* self, void* edx, uint32_t cell, uint32_t id,
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 && PotionKindOf(id) && IsHeroInventory(self)) {
if (!g_sorting && PotionKindOf(id) && Stacks(self)) {
std::lock_guard guard(g_lock);
g_entered.push_back(id);
g_entered.push_back({static_cast<uint8_t*>(self), id});
}
return previous;
}
@@ -296,11 +293,11 @@ char __fastcall Creature_UseItem(void* self, void* edx, uint32_t 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);
RE_HOOK(0x005db670, Inv_Sort);
void __fastcall Inv_Sort(void* self, void* edx, uint32_t mode) {
if (IsHeroInventory(self)) {
if (Stacks(self)) {
StackAll(static_cast<uint8_t*>(self));
}
g_sorting = true;