Skip to content

Commit 017dc5b

Browse files
committed
Add UPDATE_INVENTORY_DURABILITY (TODO #89)
Fires (no payload) when any equipped item's durability changes - combat damage, death, repairs - via a co-hook on the engine's inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's fields change (the item post-update handler FUN_005D9A90 routes every item values-update through it) plus equip/enter-world paths, so it is the engine's own durability-may-have-changed signal. Post-original we diff a per-slot {itemGuid, durability} snapshot and fire once per recompute with a real change - a death dinging all armor produces one event, not nineteen. The snapshot seeds silently on the first recompute of the process and is deliberately NOT re-seeded per enter-world: death/release/graveyard teleports re-run the setup, and a per-setup reset swallowed the death's own durability loss (verified in-game; repairs fired, deaths didn't until this was fixed). Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM field) descriptor observers register without error but are never invoked - item values-updates demonstrably arrive and player-bank observers fire in the same session; registration arithmetic fully verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2). Prime suspect is the registrar's internal wrapper lookup (FUN_00464890, the observer registry, distinct from the object manager) silently no-op'ing for item objects. Also in Player::Equipment: resolve the player via the non-throwing GUID resolver (FUN_00468460, what the engine's own bag observer uses) instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing where the player can be mid-create.
1 parent ddcb6ba commit 017dc5b

5 files changed

Lines changed: 210 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ when launching with `-console`), not as Lua functions. See the
134134
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
135135
| `QUEST_REMOVED` | `questID` |
136136
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
137+
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
137138
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
138139
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
139140
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |

TODO.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2847,7 +2847,33 @@ Same diff feeds `EQUIPMENT_SETS_CHANGED` /
28472847
module — currently those fire only on user-side mutations, not
28482848
on inventory changes that affect a set's "equipped count".
28492849

2850-
## 89. `UPDATE_INVENTORY_DURABILITY` event
2850+
## ~~89. `UPDATE_INVENTORY_DURABILITY` event~~ — DONE
2851+
2852+
**Resolution (2026-07-04, src/player/Equipment.cpp):** co-hook the
2853+
engine's inventory-alerts recompute `FUN_004C7EE0` — the routine that
2854+
walks equipment slots, reads each item's durability/broken state from
2855+
the descriptor, and unconditionally fires vanilla's
2856+
`UPDATE_INVENTORY_ALERTS` (event 501). The engine calls it whenever an
2857+
owned item's fields change (the item post-update handler `FUN_005D9A90`
2858+
routes every item values-update through it, alongside
2859+
UNIT_INVENTORY_CHANGED and the bag-update chain) plus equip and
2860+
enter-world paths — i.e., it IS the engine's own "durability may have
2861+
changed" signal. Post-original we diff a per-slot {guid, durability}
2862+
snapshot (seeded silently on the first recompute per enter-world) and
2863+
fire once per recompute with a real change. No polling, no hot hook.
2864+
2865+
**Dead end tried first:** bank-1 (ITEM field) descriptor observers per
2866+
equipped item, via the TODO #88 observer system. They register without
2867+
error but are NEVER invoked — item values-updates demonstrably arrive
2868+
(probed the values pre-pass `FUN_00465330`) and player-bank observers
2869+
fire in the same session; registration arithmetic fully verified (bank
2870+
base `FUN_00465690(1)=0x18`, bank walk item 0→1→2, anchors match
2871+
dispatch). Prime suspect: the registrar's internal wrapper lookup
2872+
(`FUN_00464890`, the observer registry — NOT the object manager)
2873+
silently no-ops for item objects. See the DEAD END note at
2874+
`OFF_DESC_ITEM_DURABILITY` in Offsets.h.
2875+
2876+
Original notes below kept for the investigation trail.
28512877

28522878
Fires when any equipped item's durability changes — picks up
28532879
combat damage, repairs, item destruction. Used by Repair-O-Matic

docs/API.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ build instructions.
9191
- [`C_EventUtils.IsEventValid(eventName)`](#c_eventutilsiseventvalideventname)
9292
- [`BAG_UPDATE_DELAYED` event](#bag_update_delayed-event)
9393
- [`PLAYER_EQUIPMENT_CHANGED` event](#player_equipment_changed-event)
94+
- [`UPDATE_INVENTORY_DURABILITY` event](#update_inventory_durability-event)
9495
- [`HEARTHSTONE_BOUND` event](#hearthstone_bound-event)
9596
- [Player input-state events (`PLAYER_STARTED_MOVING` / `LOOKING` / `TURNING` + `STOPPED_*`)](#player-input-state-events)
9697
- [`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP` events](#global_mouse_down--global_mouse_up-events)
@@ -2042,6 +2043,31 @@ server's update packet writes a new item GUID into a slot, once per
20422043
changed slot. Swapping two items (e.g. weapon ⇄ bag) fires once per
20432044
affected equipment slot.
20442045

2046+
### `UPDATE_INVENTORY_DURABILITY` event
2047+
2048+
Backport of the modern event that fires — with no payload — when an
2049+
equipped item's durability changes: combat damage, resurrection
2050+
penalty, repairs. The standard consumer pattern works unchanged:
2051+
rescan the 19 slots with `GetInventoryItemDurability` on each fire.
2052+
2053+
```lua
2054+
local f = CreateFrame("Frame")
2055+
f:RegisterEvent("UPDATE_INVENTORY_DURABILITY")
2056+
f:SetScript("OnEvent", RefreshMyDurabilityHUD)
2057+
```
2058+
2059+
Event-driven, no polling: piggybacks on the engine's own
2060+
inventory-alerts recompute — the routine behind vanilla's
2061+
`UPDATE_INVENTORY_ALERTS` (the red/yellow "broken armor man"), which
2062+
the engine runs whenever an owned item's fields change, on equip
2063+
changes, and at enter-world. On each recompute the DLL diffs a
2064+
per-slot `{item, durability}` snapshot and fires once when anything
2065+
actually changed — a death dinging all your armor produces one event,
2066+
not nineteen. Also fires when the *set* of equipped items changes
2067+
(swapping to a differently-damaged item updates durability UI, so
2068+
consumers want the refresh anyway). Bag-carried items don't trigger
2069+
it: vanilla renders durability UI for equipped gear only.
2070+
20452071
### `HEARTHSTONE_BOUND` event
20462072

20472073
Fires (with no payload) every time the player binds their

src/Offsets.h

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,23 @@ enum Offsets {
560560
//
561561
// Registrar — __fastcall(int bank /*ecx*/, uint32_t fieldOffset /*edx*/,
562562
// uint32_t guidLo, uint32_t guidHi, int size, const void *callback,
563-
// void *userArg1, void *userArg2). `bank` 4 = player fields;
564-
// `fieldOffset` is the descriptor byte offset (e.g. 0x560 backpack
565-
// slot 0). The engine's inventory setup FUN_004F8CC0 registers bags/
566-
// backpack/bank/keyring GUID ranges this way — but NOT equipment.
563+
// void *userArg1, void *userArg2). `guid` selects the watched OBJECT
564+
// (player, item, container, …); `bank` selects its field bank
565+
// (1 = ITEM, 2 = CONTAINER, 4 = PLAYER — the engine's per-bag
566+
// manager FUN_004F91A0 registers bank-2 observers on each equipped
567+
// bag's CGContainer); `fieldOffset` is BANK-relative (container
568+
// SLOT_1 = 8, not its descriptor-absolute 0x20). The engine's
569+
// inventory setup FUN_004F8CC0 registers the player's bag/backpack/
570+
// bank/keyring GUID ranges this way — but NOT equipment.
567571
FUN_DESC_OBSERVER_REGISTER = 0x00467E70,
568572

573+
// Unregister — __fastcall(int bank /*ecx*/, uint32_t fieldOffset
574+
// /*edx*/, uint32_t guidLo, uint32_t guidHi, const void *callback,
575+
// void *userArg1). Removes the node matching (callback, userArg1)
576+
// from that object+field's observer list; harmless no-op when
577+
// nothing matches. What FUN_004F91A0 calls for an unequipped bag.
578+
FUN_DESC_OBSERVER_UNREGISTER = 0x00467FB0,
579+
569580
// Observer callback ABI (verified from the dispatcher's call site at
570581
// 0x004655DC + the engine callback FUN_004F8DB0's RET 0x10):
571582
// int __fastcall cb(uint32_t fieldOffset /*ecx, as registered*/,
@@ -591,6 +602,39 @@ enum Offsets {
591602
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
592603
DESC_PLAYER_EQUIP_SLOTS = 19,
593604
DESC_OBSERVER_BANK_PLAYER = 4,
605+
606+
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
607+
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
608+
// the 0x18-byte OBJECT prefix. u32.
609+
//
610+
// DEAD END — bank-1 (ITEM field) observers register without error but
611+
// are NEVER invoked: item values-updates demonstrably arrive
612+
// (verified by probing the values pre-pass FUN_00465330) and
613+
// player-bank observers fire in the same session, yet item-bank
614+
// callbacks stay silent. Registration arithmetic was fully verified
615+
// (bank base FUN_00465690(1)=0x18, next-bank walk FUN_004656E0
616+
// item: 0→1→2, anchors match dispatch), so the suspect is the
617+
// registrar's internal wrapper lookup (FUN_00464890 — the observer
618+
// registry, NOT the object manager) silently no-op'ing for item
619+
// objects. UPDATE_INVENTORY_DURABILITY is instead backed by
620+
// FUN_INVENTORY_ALERTS_RECOMPUTE below. Constants kept for the
621+
// investigation trail.
622+
DESC_OBSERVER_BANK_ITEM = 1,
623+
OFF_DESC_ITEM_DURABILITY = 0x88,
624+
625+
// The engine's inventory-alerts recompute — walks the equipment
626+
// slots, reads each item's ITEM_FIELD_DURABILITY / MAXDURABILITY /
627+
// broken flag from the descriptor, fills the per-slot alert table at
628+
// [DAT_00B71F60], and UNCONDITIONALLY fires UPDATE_INVENTORY_ALERTS
629+
// (event 501) at the end. The engine calls it whenever an owned
630+
// item's fields change (the item post-update handler FUN_005D9A90 —
631+
// which also fires UNIT_INVENTORY_CHANGED and the bag-update chain —
632+
// routes every item values-update through it) plus equip and
633+
// enter-world paths. That makes it the engine's own "durability may
634+
// have changed" signal: Player::Equipment co-hooks it and diffs a
635+
// {guid, durability} snapshot per slot to back
636+
// UPDATE_INVENTORY_DURABILITY. void __fastcall(), no args.
637+
FUN_INVENTORY_ALERTS_RECOMPUTE = 0x004C7EE0,
594638
// ItemMgr::GetItemBySlot — __thiscall(this, slot) → CGItem* (NULL if empty).
595639
// Slot is the engine's linearized slot index, not bagID/slot tuple.
596640
FUN_ITEMMGR_GET_ITEM_BY_SLOT = 0x006228A0,
@@ -1153,6 +1197,7 @@ enum Offsets {
11531197
OBJ_TYPE_ITEM = 2,
11541198
OBJ_TYPE_CONTAINER = 4,
11551199
OBJ_TYPE_UNIT = 8,
1200+
OBJ_TYPE_PLAYER = 0x10, // what the engine's bag observer FUN_004F8DB0 passes
11561201
OBJ_TYPE_GAMEOBJECT = 0x20, // 1<<5; passed by FUN_0052AA20 (hover-tooltip populator).
11571202

11581203
// `CGObject::GetName` — returns the display-name `const char *` for

src/player/Equipment.cpp

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,25 @@
3232
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
3333
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
3434
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
35+
//
36+
// Also `UPDATE_INVENTORY_DURABILITY` (no payload) — fires when any
37+
// equipped item's durability changes (combat damage, death, repairs) —
38+
// via a co-hook on the engine's inventory-alerts recompute
39+
// (FUN_INVENTORY_ALERTS_RECOMPUTE): the engine itself calls that whenever
40+
// an owned item's fields change (its item post-update handler FUN_005D9A90
41+
// routes every item values-update through it) and on equip/enter-world
42+
// paths, so it IS the engine's own "durability may have changed" signal.
43+
// Post-original we diff a per-slot {itemGuid, durability} snapshot and
44+
// fire only on a real change. NOT done with bank-1 (ITEM field) descriptor
45+
// observers: those register without error but are never invoked — the
46+
// registrar's internal wrapper lookup (FUN_00464890, the observer
47+
// registry, distinct from the object manager) silently no-ops for item
48+
// objects, verified empirically (item values-updates arrive and the
49+
// player-bank observers fire, item-bank ones never do).
3550

3651
#include "Game.h"
3752
#include "Offsets.h"
53+
#include "equipmentset/Locations.h"
3854
#include "event/Custom.h"
3955
#include "unit/Identity.h"
4056

@@ -46,17 +62,28 @@ namespace {
4662

4763
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
4864
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
49-
50-
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
65+
constexpr const char *kEvtDurability = "UPDATE_INVENTORY_DURABILITY";
66+
const Event::Custom::AutoReserve _r2{kEvtDurability};
5167

5268
// Whether the slot currently holds an item — read from the player's
5369
// inventory-manager GUID array, the same source the engine's own bag
5470
// observer (FUN_004F8DB0) treats as the post-change truth when its field
55-
// observer fires.
56-
bool SlotHasItem(int slot0Based) {
71+
// observer fires. The player is resolved from the GUID the observer
72+
// callback receives, via the NON-THROWING object resolver — the same call
73+
// FUN_004F8DB0 makes. NOT via FUN_RESOLVE_UNIT_TOKEN("player"): that's the
74+
// assert-frame helper that raises a Lua error when the token doesn't
75+
// resolve, and this runs inside SMSG_UPDATE_OBJECT processing, where the
76+
// player object can be mid-create and momentarily unresolvable — a Lua
77+
// error thrown from there unwinds through raw engine code.
78+
bool SlotHasItem(uint32_t guidLo, uint32_t guidHi, int slot0Based) {
79+
using ResolveByGUID_t = void *(__fastcall *)(uint32_t typeMask,
80+
const char *debugName,
81+
uint32_t guidLo, uint32_t guidHi,
82+
int line);
5783
auto resolve =
58-
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
59-
auto *player = static_cast<const uint8_t *>(resolve("player"));
84+
reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
85+
auto *player = static_cast<const uint8_t *>(resolve(
86+
Offsets::OBJ_TYPE_PLAYER, "ClassicAPI", guidLo, guidHi, 0x172));
6087
if (player == nullptr)
6188
return false;
6289
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
@@ -69,35 +96,50 @@ bool SlotHasItem(int slot0Based) {
6996
return guids != nullptr && guids[slot0Based] != 0;
7097
}
7198

99+
// --- PLAYER_EQUIPMENT_CHANGED (player-descriptor observers) -------------
100+
72101
// Descriptor-field observer callback — ABI per the Offsets.h note:
73102
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
74103
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
75104
// when the watched equipment GUID field actually changed.
76105
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
77-
uint32_t /*guidLo*/, uint32_t /*guidHi*/,
106+
uint32_t guidLo, uint32_t guidHi,
78107
const uint32_t * /*oldValue*/,
79108
void * /*userArg*/) {
80109
const int slot0 =
81110
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
82111
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
83112
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
84-
slot0 + 1, SlotHasItem(slot0));
113+
slot0 + 1,
114+
SlotHasItem(guidLo, guidHi, slot0));
85115
}
86116
return 1;
87117
}
88118

89119
// Co-hook on the engine's inventory observer setup: after it registers the
90120
// bag-range observers, register ours for the equipment range it skips.
91121
// Same registrar, same shape — only the field offsets differ.
92-
using ObserverSetup_t = void(__fastcall *)();
93-
ObserverSetup_t g_origSetup = nullptr;
94-
95122
using RegisterObserver_t = void(__fastcall *)(
96123
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
97124
const void *callback, void *userArg1, void *userArg2);
125+
using ObserverSetup_t = void(__fastcall *)();
126+
ObserverSetup_t g_origSetup = nullptr;
127+
128+
// One-time (per process) seed latch for the durability diff below — the
129+
// very first recompute seeds the snapshot silently instead of firing for
130+
// the initial gear population. Deliberately NOT reset per enter-world:
131+
// death/release/graveyard teleports re-run the setup, and a per-setup
132+
// reset made the post-teleport recompute re-seed silently — swallowing
133+
// the death's own durability loss (verified in-game: repairs fired,
134+
// deaths didn't). Keeping the snapshot across loading screens means
135+
// changes that land around them diff normally; the cost is at most one
136+
// spurious fire on relog to a different character, which matches
137+
// modern's login-time fire anyway.
138+
bool g_durSnapValid = false;
98139

99140
void __fastcall ObserverSetup_h() {
100141
g_origSetup();
142+
101143
const uint64_t guid = Unit::Identity::PlayerGuid();
102144
if (guid == 0)
103145
return;
@@ -117,6 +159,60 @@ static const Game::HookAutoRegister _setupHook{
117159
reinterpret_cast<void *>(&ObserverSetup_h),
118160
reinterpret_cast<void **>(&g_origSetup)};
119161

162+
// --- UPDATE_INVENTORY_DURABILITY (alerts-recompute co-hook) --------------
163+
164+
struct SlotDurability {
165+
uint64_t guid; // equipped item, 0 = empty slot
166+
uint32_t durability; // ITEM_FIELD_DURABILITY, 0 when empty/unresolved
167+
};
168+
SlotDurability g_durSnap[EquipmentSet::SLOT_COUNT];
169+
170+
uint32_t ItemDurability(uint64_t guid) {
171+
if (guid == 0)
172+
return 0;
173+
const uint8_t *item = EquipmentSet::Locations::ResolveItemByGUID(guid);
174+
if (item == nullptr)
175+
return 0;
176+
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
177+
item + Offsets::OFF_ITEM_DESCRIPTOR);
178+
if (descriptor == nullptr)
179+
return 0;
180+
return *reinterpret_cast<const uint32_t *>(
181+
descriptor + Offsets::OFF_DESCRIPTOR_DURABILITY);
182+
}
183+
184+
using AlertsRecompute_t = void(__fastcall *)();
185+
AlertsRecompute_t g_origAlertsRecompute = nullptr;
186+
187+
void __fastcall AlertsRecompute_h() {
188+
g_origAlertsRecompute();
189+
190+
uint64_t paperdoll[EquipmentSet::SLOT_COUNT];
191+
EquipmentSet::Locations::SnapshotPaperdoll(paperdoll);
192+
193+
bool changed = false;
194+
for (int i = 0; i < EquipmentSet::SLOT_COUNT; ++i) {
195+
const SlotDurability cur{paperdoll[i], ItemDurability(paperdoll[i])};
196+
if (g_durSnap[i].guid != cur.guid ||
197+
g_durSnap[i].durability != cur.durability) {
198+
changed = true;
199+
g_durSnap[i] = cur;
200+
}
201+
}
202+
203+
if (!g_durSnapValid) {
204+
g_durSnapValid = true; // first recompute of the session seeds only
205+
return;
206+
}
207+
if (changed)
208+
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
209+
}
210+
211+
static const Game::HookAutoRegister _alertsHook{
212+
Offsets::FUN_INVENTORY_ALERTS_RECOMPUTE,
213+
reinterpret_cast<void *>(&AlertsRecompute_h),
214+
reinterpret_cast<void **>(&g_origAlertsRecompute)};
215+
120216
} // namespace
121217

122218
} // namespace Player::Equipment

0 commit comments

Comments
 (0)