Skip to content

Commit 17cf323

Browse files
committed
Add PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent) (TODO #88)
The earlier investigation tried to hook a firer of UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites trigger on equip because equipment descriptor writes are silent — the engine registers descriptor-field observers (the __AUCMirrorHandler__ system: registrar 0x00467E70, dispatcher 0x00465570 with per-node mirror + memcmp change detection) for the bag/backpack/bank/keyring GUID ranges but never for equipment 0x4A8..0x538. So register our own: co-hook the engine's inventory observer setup (FUN_004F8CC0, once per enter-world) and, post-original, register one observer per equipment GUID field through the engine's registrar — same bank/size/GUID shape as the engine's bag observers, engine- managed lifetime, no polling, no hot-function hook. The dispatcher invokes us only on a real change. Callback ABI verified from the dispatcher call site at 0x004655DC and the engine callback FUN_004F8DB0's RET 0x10: __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*, userArg) -> ret 0x10 equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard); hasCurrent (1/nil) is read from the player invMgr GUID array — the same post-change truth the engine's bag observer reads.
1 parent a0b7d03 commit 17cf323

5 files changed

Lines changed: 229 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ when launching with `-console`), not as Lua functions. See the
122122
| `NAME_PLATE_CREATED` | `nameplateFrame` |
123123
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
124124
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
125+
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
125126
| `PLAYER_FOCUS_CHANGED` | *(none)* |
126127
| `PLAYER_STARTED_LOOKING` | *(none)* |
127128
| `PLAYER_STOPPED_LOOKING` | *(none)* |

TODO.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2725,7 +2725,26 @@ matching modern's 3-tuple. `CastSpellNoToggle("<name>")` macros are
27252725
also recognized because `Spell::CastNoToggle` already hooks the same
27262726
parser to register that pattern.
27272727

2728-
## 88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event
2728+
## ~~88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event~~ — DONE
2729+
2730+
**Resolution (2026-07-04, src/player/Equipment.cpp):** the 2026-05-22
2731+
investigation kept trying to hook a FIRER of UNIT_INVENTORY_CHANGED.
2732+
The winning move was the opposite: the engine has a generic
2733+
descriptor-field observer system (the "__AUCMirrorHandler__" nodes —
2734+
registrar `FUN_00467E70`, dispatcher `FUN_00465570` with per-node
2735+
mirror + memcmp change detection). `FUN_004F8CC0` registers observers
2736+
for the bag/backpack/bank/keyring GUID ranges but never for equipment
2737+
`0x4A8..0x538` — so we co-hook it and REGISTER OUR OWN observer per
2738+
equipment field, mirroring the engine's registration exactly (bank 4,
2739+
size 8, player GUID). Callback ABI verified from the dispatcher call
2740+
site at `0x004655DC` + `FUN_004F8DB0`'s `RET 0x10`:
2741+
`__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
2742+
oldValue*, userArg)`. hasCurrent read from the invMgr GUID array (the
2743+
same "current truth" the engine's bag callback reads). No polling, no
2744+
hot hook, engine-managed lifetime (setup re-runs each enter-world).
2745+
Offsets documented at `FUN_DESC_OBSERVER_REGISTER` in Offsets.h.
2746+
2747+
Original notes below kept for the investigation trail.
27292748

27302749
WotLK-era event that fires every time a paperdoll slot changes
27312750
(equip, unequip, swap). Heavily used by gear-tracking,

docs/API.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ build instructions.
9090
- [Events](#events)
9191
- [`C_EventUtils.IsEventValid(eventName)`](#c_eventutilsiseventvalideventname)
9292
- [`BAG_UPDATE_DELAYED` event](#bag_update_delayed-event)
93+
- [`PLAYER_EQUIPMENT_CHANGED` event](#player_equipment_changed-event)
9394
- [`HEARTHSTONE_BOUND` event](#hearthstone_bound-event)
9495
- [Player input-state events (`PLAYER_STARTED_MOVING` / `LOOKING` / `TURNING` + `STOPPED_*`)](#player-input-state-events)
9596
- [`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP` events](#global_mouse_down--global_mouse_up-events)
@@ -2003,6 +2004,43 @@ event handlers.
20032004
> currently won't trigger `BAG_UPDATE_DELAYED`. Player bag (0..4)
20042005
> and bank (5..10) updates work normally — the 95% case.
20052006
2007+
### `PLAYER_EQUIPMENT_CHANGED` event
2008+
2009+
Backport of the WotLK-era event that fires once per paperdoll slot
2010+
change — equip, unequip, or swap:
2011+
2012+
```
2013+
PLAYER_EQUIPMENT_CHANGED: equipmentSlot, hasCurrent
2014+
```
2015+
2016+
- **`equipmentSlot`** (number) — the 1-based inventory slot that
2017+
changed (`1` = head … `19` = tabard; same numbering as
2018+
`GetInventoryItemLink("player", slot)`).
2019+
- **`hasCurrent`**`1` when the slot now holds an item, `nil` when
2020+
it's now empty (write `if hasCurrent then` as in modern code —
2021+
vanilla's event dispatcher can't push real booleans).
2022+
2023+
Vanilla only fires `UNIT_INVENTORY_CHANGED("player")`, which doesn't
2024+
say *which* slot changed; this event lets gear trackers, stat sheets,
2025+
and tooltip decorators refresh exactly one slot instead of rescanning
2026+
all 19.
2027+
2028+
```lua
2029+
local f = CreateFrame("Frame")
2030+
f:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
2031+
f:SetScript("OnEvent", function()
2032+
print("slot", arg1, arg2 and "equipped" or "now empty")
2033+
end)
2034+
```
2035+
2036+
Fully event-driven — no polling. The DLL registers change observers
2037+
for the 19 equipment GUID fields of the player descriptor through the
2038+
engine's own field-observer system (the same mechanism the engine
2039+
uses to watch bag slots), so the event fires exactly when the
2040+
server's update packet writes a new item GUID into a slot, once per
2041+
changed slot. Swapping two items (e.g. weapon ⇄ bag) fires once per
2042+
affected equipment slot.
2043+
20062044
### `HEARTHSTONE_BOUND` event
20072045

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

src/Offsets.h

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,55 @@ enum Offsets {
542542
// addon's CHAT_MSG_* OnEvent.
543543
FUN_CHAT_DISPATCH = 0x0049A870,
544544
// Per-player inventory manager lives at this offset on the player object.
545+
// +0x00 = u32 slot count (OFF_INVMGR_SLOT_COUNT), +0x04 = u64* GUID
546+
// array (see OFF_INVMGR_GUID_ARRAY below for the slot-range map).
545547
OFF_PLAYER_INVENTORY_MANAGER = 0x1D38,
548+
OFF_INVMGR_SLOT_COUNT = 0x00,
549+
550+
// --- Descriptor-field observer system --------------------------------
551+
// The engine's generic "watch a descriptor field range for changes"
552+
// mechanism (nodes tagged "__AUCMirrorHandler__", 0x30 bytes). The
553+
// registrar attaches a node to the object's per-field observer anchors;
554+
// each node keeps a private MIRROR of the watched bytes, seeded from
555+
// the live value at registration. During SMSG_UPDATE_OBJECT processing
556+
// the dispatcher FUN_00465570 memcmps live vs mirror and invokes the
557+
// callback only on a real change (the mirror is then re-synced by
558+
// FUN_004667A0). Firing engine events from the callback is sanctioned —
559+
// the engine's own bag observer does exactly that.
560+
//
561+
// Registrar — __fastcall(int bank /*ecx*/, uint32_t fieldOffset /*edx*/,
562+
// 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.
567+
FUN_DESC_OBSERVER_REGISTER = 0x00467E70,
568+
569+
// Observer callback ABI (verified from the dispatcher's call site at
570+
// 0x004655DC + the engine callback FUN_004F8DB0's RET 0x10):
571+
// int __fastcall cb(uint32_t fieldOffset /*ecx, as registered*/,
572+
// uint32_t size /*edx*/,
573+
// uint32_t guidLo, uint32_t guidHi,
574+
// const uint32_t *oldValue /*the node's mirror*/,
575+
// void *userArg1) → return 1, callee cleans 0x10.
576+
// The live (new) value is NOT passed — read it from the object (the
577+
// engine's bag callback reads the invMgr GUID array).
578+
579+
// The engine's inventory observer setup — registers the bag-slot
580+
// GUID-field observers above, once per enter-world (sole caller is the
581+
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
582+
// Player::Equipment co-hooks it to register the equipment-slot
583+
// observers the engine never installs; the co-hook inherits the exact
584+
// engine timing + lifetime (nodes die with the player object).
585+
FUN_INV_OBSERVER_SETUP = 0x004F8CC0,
586+
587+
// PLAYER_FIELD_INV_SLOT_HEAD — first of the 19 equipment-slot u64 GUID
588+
// fields in the player descriptor (0x4A8..0x538, stride 8; 0x540 is
589+
// the first equipped-bag slot). 0-based slot = (offset - 0x4A8) >> 3;
590+
// Lua inventory slot (GetInventoryItemLink etc.) = that + 1.
591+
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
592+
DESC_PLAYER_EQUIP_SLOTS = 19,
593+
DESC_OBSERVER_BANK_PLAYER = 4,
546594
// ItemMgr::GetItemBySlot — __thiscall(this, slot) → CGItem* (NULL if empty).
547595
// Slot is the engine's linearized slot index, not bagID/slot tuple.
548596
FUN_ITEMMGR_GET_ITEM_BY_SLOT = 0x006228A0,

src/player/Equipment.cpp

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// This file is part of ClassicAPI.
2+
//
3+
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
4+
// of the GNU General Public License as published by the Free Software Foundation, either
5+
// version 3 of the License, or (at your option) any later version.
6+
//
7+
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
8+
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
9+
// PURPOSE. See the GNU General Public License for more details.
10+
//
11+
// You should have received a copy of the GNU General Public License along with
12+
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
13+
14+
// `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` — the WotLK-era
15+
// per-slot paperdoll event. Vanilla only fires UNIT_INVENTORY_CHANGED for
16+
// the player, which doesn't say WHICH slot changed; gear trackers and stat
17+
// sheets built against 3.3+ gate their refresh on this event.
18+
//
19+
// Fully event-driven via the engine's own descriptor-field observer system
20+
// (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h). The engine's
21+
// inventory setup (FUN_INV_OBSERVER_SETUP, once per enter-world) registers
22+
// change observers for the bag/backpack/bank/keyring GUID fields of the
23+
// player descriptor — but never for the 19 equipment slots (0x4A8..0x538).
24+
// We co-hook that setup and register our own observer per equipment field,
25+
// mirroring the engine's registration shape exactly (bank 4, size 8). The
26+
// dispatcher invokes us only when a slot's item GUID actually changed
27+
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
28+
// on our side, and the nodes share the engine observers' lifetime (they
29+
// die with the player object; the setup re-runs each enter-world).
30+
//
31+
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
32+
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
33+
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
34+
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
35+
36+
#include "Game.h"
37+
#include "Offsets.h"
38+
#include "event/Custom.h"
39+
#include "unit/Identity.h"
40+
41+
#include <cstdint>
42+
43+
namespace Player::Equipment {
44+
45+
namespace {
46+
47+
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
48+
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
49+
50+
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
51+
52+
// Whether the slot currently holds an item — read from the player's
53+
// inventory-manager GUID array, the same source the engine's own bag
54+
// observer (FUN_004F8DB0) treats as the post-change truth when its field
55+
// observer fires.
56+
bool SlotHasItem(int slot0Based) {
57+
auto resolve =
58+
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
59+
auto *player = static_cast<const uint8_t *>(resolve("player"));
60+
if (player == nullptr)
61+
return false;
62+
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
63+
const uint32_t count = *reinterpret_cast<const uint32_t *>(
64+
invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
65+
if (static_cast<uint32_t>(slot0Based) >= count)
66+
return false;
67+
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
68+
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
69+
return guids != nullptr && guids[slot0Based] != 0;
70+
}
71+
72+
// Descriptor-field observer callback — ABI per the Offsets.h note:
73+
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
74+
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
75+
// when the watched equipment GUID field actually changed.
76+
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
77+
uint32_t /*guidLo*/, uint32_t /*guidHi*/,
78+
const uint32_t * /*oldValue*/,
79+
void * /*userArg*/) {
80+
const int slot0 =
81+
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
82+
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
83+
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
84+
slot0 + 1, SlotHasItem(slot0));
85+
}
86+
return 1;
87+
}
88+
89+
// Co-hook on the engine's inventory observer setup: after it registers the
90+
// bag-range observers, register ours for the equipment range it skips.
91+
// Same registrar, same shape — only the field offsets differ.
92+
using ObserverSetup_t = void(__fastcall *)();
93+
ObserverSetup_t g_origSetup = nullptr;
94+
95+
using RegisterObserver_t = void(__fastcall *)(
96+
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
97+
const void *callback, void *userArg1, void *userArg2);
98+
99+
void __fastcall ObserverSetup_h() {
100+
g_origSetup();
101+
const uint64_t guid = Unit::Identity::PlayerGuid();
102+
if (guid == 0)
103+
return;
104+
auto reg = reinterpret_cast<RegisterObserver_t>(
105+
Offsets::FUN_DESC_OBSERVER_REGISTER);
106+
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
107+
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
108+
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
109+
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
110+
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
111+
nullptr, nullptr);
112+
}
113+
}
114+
115+
static const Game::HookAutoRegister _setupHook{
116+
Offsets::FUN_INV_OBSERVER_SETUP,
117+
reinterpret_cast<void *>(&ObserverSetup_h),
118+
reinterpret_cast<void **>(&g_origSetup)};
119+
120+
} // namespace
121+
122+
} // namespace Player::Equipment

0 commit comments

Comments
 (0)