unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss

Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.

Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.

Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
This commit is contained in:
Brues
2026-07-30 00:45:00 -05:00
parent 4e1c1863e9
commit 46597f6587
4 changed files with 206 additions and 0 deletions
+55
View File
@@ -129,6 +129,7 @@ build instructions.
- [`QUEST_REMOVED` event](#quest_removed-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)](#update_mouseover_unit-event-loss-fire-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [Expansion](#expansion)
@@ -3123,6 +3124,60 @@ byte reads (cheap) and naturally handles the case where one packet
carries multiple rep updates whose threshold crossings ripple AT_WAR
state across unrelated slots.
### `UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)
`UPDATE_MOUSEOVER_UNIT` exists in the vanilla event table and fires when a
mouseover unit is **gained**, but the 1.12 engine **never fires it on
loss** — moving the cursor off a unit clears the mouseover and signals
nothing. Modern WoW fires it for both gain and loss, so addons observing
`UnitExists("mouseover")` can react when it becomes false. We restore the
loss fire.
Payload is unchanged (none in vanilla — same as retail): the event
carries no args; handlers read `UnitExists("mouseover")`, which is
`nil`/false at fire time on the loss path.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
f:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" then
if UnitExists("mouseover") then
-- gained (or changed to) a mouseover unit
else
-- mouseover unit lost (the vanilla gap this fills)
end
end
end)
```
**Transitions and who fires them:**
| From | To | Fires? | Source |
|------|----|:------:|--------|
| unit | nothing | yes | ClassicAPI (silent in stock 1.12) |
| unit | gameobject | yes | ClassicAPI (mouseover unit genuinely lost) |
| gameobject | nothing | no | — (never fired a gain; not a unit) |
| anything | unit | yes | engine's own inline fire (gain / unit→unit) |
**Implementation notes**
The mouseover GUID lives in two globals written **only** by the engine's
mouseover set/clear chokepoint (`0x00492890`), so every gain / loss /
change flows through it — on a unit gain it fires `UPDATE_MOUSEOVER_UNIT`
itself, on loss it clears the GUID silently. We co-hook that function:
resolve whether the mouseover is a *unit* before and after the original,
and fire on the unit→non-unit transition. Gating on unit *type* (not just
"GUID present → absent") is load-bearing — the same GUID slot also holds
gameobjects and items, which never fire a gain, so the type check is what
keeps moving off a herb node or chest from spuriously firing. The
`!hasUnit` half prevents a double-fire on any →unit transition the engine
already covered.
The target is change-gated by its callers (if it ran per-frame the gain
event would spam while hovering), so it's a cool hook — no per-frame
overhead.
### `UPDATE_SHAPESHIFT_FORM` event
Fires whenever the local player's shapeshift form changes — entering
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+25
View File
@@ -554,6 +554,31 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
// these (see the token-dispatch list above). Written ONLY by
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
// `Unit::Mouseover` to detect the loss transition.
VAR_MOUSEOVER_GUID_LO = 0x00B4E2C8,
VAR_MOUSEOVER_GUID_HI = 0x00B4E2CC,
// The engine's single mouseover set/clear chokepoint —
// `__stdcall(guidLo, guidHi, arg3, arg4)` (early returns are all
// `ret 0x10`, and the caller pushes 4 without cleaning). It is the
// ONLY writer of `VAR_MOUSEOVER_GUID_*`, so every mouseover
// gain/loss/change flows through it. On a unit gain it fires
// `UPDATE_MOUSEOVER_UNIT` itself (via `FUN_FIRE_EVENT_NO_ARGS(0x150)`
// in the friendly/hostile-unit tooltip case); on loss it clears the
// GUID but fires nothing — the gap `Unit::Mouseover` closes.
// Change-gated by its callers (if it ran per-frame the gain fire
// would spam every frame while hovering, which vanilla doesn't), so
// it's a cool hook target.
FUN_SET_MOUSEOVER_UNIT = 0x00492890,
// Event ID of `UPDATE_MOUSEOVER_UNIT` (name-table slot
// `0x00BE16D8` → `(0x16D8 - 0x1198) / 4`). Fired with no args via
// `FUN_FIRE_EVENT_NO_ARGS`; Lua handlers read `UnitExists("mouseover")`.
EVENT_UPDATE_MOUSEOVER_UNIT = 0x150,
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
// — the function ends with `ret 0xc`, so the callee pops the
+115
View File
@@ -0,0 +1,115 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// UPDATE_MOUSEOVER_UNIT loss-fire polyfill.
//
// Vanilla fires `UPDATE_MOUSEOVER_UNIT` only when a mouseover is *gained*
// (the engine's `FUN_SET_MOUSEOVER_UNIT` fires it inline in the
// friendly/hostile-unit tooltip case). When the cursor leaves the unit,
// the engine clears the mouseover GUID but fires nothing — so an addon
// that reacted to `UnitExists("mouseover")` becoming true never gets told
// when it becomes false. Retail fires the event for both gain and loss.
//
// We close the gap by co-hooking the single set/clear chokepoint (it is
// the ONLY writer of the mouseover GUID globals, so every gain / loss /
// change flows through it): resolve whether the mouseover was a *unit*
// before the original, then after, and fire `UPDATE_MOUSEOVER_UNIT` when
// it went from a unit to a non-unit.
//
// The unit-vs-non-unit test matters: the mouseover GUID also holds
// gameobjects (and items), but the engine only fires the *gain* event for
// units — the gameobject/item cases never fire it. So a naive
// "GUID present -> absent" test spuriously fires when you move off a
// gameobject that never fired a gain. Gating on "old was a unit, new
// isn't" fixes that while still matching retail's other cases:
//
// * unit -> nothing : fire (retail fires)
// * unit -> gameobject : fire (retail fires — mouseover unit lost)
// * gameobject -> nothing: no (bug fixed; no gain ever fired)
// * X -> unit : engine already fired inline; we don't (new is
// a unit, so our `!newIsUnit` guard is false —
// no double-fire on gain or unit->unit change)
//
// The event carries no arguments in vanilla (same as retail); handlers
// read `UnitExists("mouseover")` — which is false at fire time on the loss
// path, exactly matching retail semantics.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Mouseover {
namespace {
// `FUN_SET_MOUSEOVER_UNIT` is `__stdcall(guidLo, guidHi, arg3, arg4)` —
// see Offsets.h. We don't use the args; they pass straight through.
using SetMouseover_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4);
using FireEventNoArgs_t = void(__fastcall *)(int eventID);
// `FUN_OBJECT_RESOLVE_BY_GUID(typeMask, debugName, guidLo, guidHi, prio)`
// — keeps the object only if its type flags intersect `typeMask`, so the
// unit|player mask returns non-null only for a live unit / player / pet /
// MC'd creature (never a gameobject or item). Same primitive
// `Unit::Flags::ResolveUnitOrPlayerByGuid` uses.
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
// True iff the current mouseover GUID resolves to a unit (not a
// gameobject / item / nothing).
bool MouseoverIsUnit() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_LO));
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_HI));
if ((lo | hi) == 0)
return false;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return fn(kUnitOrPlayerMask, "UPDATE_MOUSEOVER_UNIT", lo, hi, 0) != nullptr;
}
SetMouseover_t SetMouseover_o = nullptr;
void __stdcall SetMouseover_h(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4) {
const bool hadUnit = MouseoverIsUnit();
SetMouseover_o(guidLo, guidHi, arg3, arg4);
const bool hasUnit = MouseoverIsUnit();
// Only the unit-loss transition is missing from the engine. Any
// ->unit transition (gain / unit->unit change) already fired inline
// in the original, so `!hasUnit` prevents a double-fire there.
if (hadUnit && !hasUnit) {
auto fire = reinterpret_cast<FireEventNoArgs_t>(
static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS));
fire(Offsets::EVENT_UPDATE_MOUSEOVER_UNIT);
}
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_SET_MOUSEOVER_UNIT,
reinterpret_cast<void *>(&SetMouseover_h),
reinterpret_cast<void **>(&SetMouseover_o)};
} // namespace
} // namespace Unit::Mouseover