hearth: gate HEARTHSTONE_BOUND on in-world flag, not location diff

Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.

A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.

Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
This commit is contained in:
Brues
2026-07-31 12:58:32 -05:00
parent f91ffd3189
commit cd674aa115
3 changed files with 58 additions and 22 deletions
+9 -3
View File
@@ -2539,9 +2539,15 @@ it: vanilla renders durability UI for equipped gear only.
### `HEARTHSTONE_BOUND` event
Fires (with no payload) every time the player binds their
hearthstone at an innkeeper. Polyfills modern WoW's event of the
same name — addons listen to `HEARTHSTONE_BOUND` and re-read
`GetBindLocation()` to refresh whatever bind-location UI they show.
hearthstone at an innkeeper — including a rebind at the same inn.
Polyfills modern WoW's event of the same name — addons listen to
`HEARTHSTONE_BOUND` and re-read `GetBindLocation()` to refresh
whatever bind-location UI they show.
> Note: the server re-sends the bind packet on map/zone transitions
> (behind the loading screen). Those resyncs are suppressed by gating
> on the engine's in-world flag, so the event fires only for an actual
> innkeeper bind, never on a map change.
```lua
local f = CreateFrame("Frame")
+22 -7
View File
@@ -3825,6 +3825,20 @@ enum Offsets {
// `Script_IsInInstance`).
VAR_CURRENT_MAP_ID = 0x00B4E378,
// "World is live" flag — 1 while the player is fully in the world, 0 during
// any loading screen (initial login, zone/instance transition, logout to
// glue). Set to 1 by the enter-world init `FUN_004908C0` (guarded
// `if (==0){=1;...}`) and back to 0 by the world-teardown `FUN_00490A80`
// (`if(!=0){=0;...}`) that runs at the start of every transition. Byte flag.
// The clean pollable gate for "can this happen in the world right now" —
// e.g. suppressing server resync packets that arrive behind a loading
// screen. Verified via the enter/leave pair in Ghidra.
//
// (NOTE: 0x00B4E37C is NOT this flag — an earlier note wrongly claimed it
// was. That address is instance grace-period state, read only by the
// "recently left an instance" 90-second check in `FUN_00495c90`.)
VAR_IN_WORLD = 0x00B4B424,
// Local player's current AreaTable.dbc area ID (u32 storage; only
// the low 16 bits are used — the value is broadcast over the wire
// as a u16 in `SMSG_PARTY_MEMBER_STATS`). Written by the zone-change
@@ -4874,18 +4888,19 @@ enum Offsets {
//
// Single caller (the packet dispatcher's jump table for opcode
// 0x155) — quiet hook target, no contention with other DLLs.
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`
// every time the user re-binds at an innkeeper. Gating off the
// bind-valid flag's transition distinguishes "initial sync" (the
// one we suppress) from "user rebound at an inn" (the one we
// fire on — even when the area string stays the same, since the
// bind ACTION is what fires the event).
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`.
// Two packets must be suppressed so the event reflects only a real
// innkeeper bind: (1) the initial post-login/char-switch sync
// (gated on VAR_BIND_POINT_VALID being 0 before the handler runs),
// and (2) Turtle's per-map-transition resync, which re-sends the
// packet behind a loading screen — gated on VAR_IN_WORLD (the
// player can't bind mid-load). See `Player::HearthstoneBound`.
FUN_BINDPOINT_UPDATE_HANDLER = 0x005ED3C0,
// "Bind point is valid" flag. Set to 1 by the BINDPOINTUPDATE
// handler after the packet has been fully parsed. Zeroed by
// `FUN_005E2510` (the per-character-entry init routine).
// Reading this BEFORE the handler runs distinguishes initial
// sync (`== 0`) from a real re-bind (`== 1`).
// sync (`== 0`) from a later packet (`== 1`).
VAR_BIND_POINT_VALID = 0x00C4D4E0,
// SMSG_QUESTGIVER_QUEST_COMPLETE handler (opcode 0x191). Server
+27 -12
View File
@@ -10,19 +10,27 @@
// `HEARTHSTONE_BOUND` event — polyfills modern WoW's event of the
// same name. Fires every time the player binds their hearthstone at
// an innkeeper, even when they re-bind at the SAME inn — matches
// an innkeeper, even when they re-bind at the SAME inn — matching
// modern semantics where the bind ACTION fires the event regardless
// of whether the location string changes.
// of whether the location changes.
//
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0` and
// gates on `VAR_BIND_POINT_VALID` to suppress only the initial
// post-login sync. The engine zeroes that flag during per-character
// entry init (`FUN_005E2510`); the handler sets it to 1 after
// parsing. If the flag is 0 before the original runs, this packet
// is the initial sync — don't fire. Any subsequent invocation is
// a real innkeeper rebind. Character switch is handled automatically
// because the entry init re-zeroes the flag, so each character's
// first update is also treated as sync.
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0`. Two
// non-bind packets must be suppressed:
//
// 1. Initial post-login / character-switch sync — gated on
// `VAR_BIND_POINT_VALID`. The engine zeroes that flag during
// per-character entry init (`FUN_005E2510`); the handler sets it
// to 1 after parsing. Flag 0 before the original runs ⇒ initial
// sync, don't fire.
// 2. Map/zone-transition resync — Turtle's server re-sends this
// packet on every map change with the (unchanged) bind location.
// The valid flag is already 1 by then, so (1) doesn't catch it.
// But those packets arrive behind a LOADING SCREEN, and the
// player can't bind mid-load — so gate on `VAR_IN_WORLD` (the
// engine's "world is live" flag, 0 during any loading screen).
// A genuine rebind always arrives with the world live (flag 1),
// so this suppresses the resync while still firing for a rebind
// at the same innkeeper.
//
// Event has no payload — addons call `GetBindLocation()` to read
// the new location, matching modern semantics.
@@ -51,7 +59,14 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
BindPointUpdateHandler_o(packetBuffer);
if (wasValid == 0)
return; // initial post-login sync — not a user rebind
return; // initial post-login / char-switch sync — not a user rebind
// Resync packets ride the enter-world burst behind a loading screen;
// a real innkeeper bind only happens with the world live. The player
// can't bind mid-load, so a not-in-world packet is always a resync.
if (*reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "");