Compare commits

...
8 Commits
Author SHA1 Message Date
Brues 555ea02447 loot: add C_Loot.LootAllCorpses to auto-loot nearby corpses
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.

Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.

Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).

Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
2026-07-31 14:51:24 -05:00
Brues e3f5318747 bag: gate BAG_UPDATE_DELAYED on VAR_IN_WORLD, drop the PEW latch
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.

The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).

Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
2026-07-31 13:04:15 -05:00
Brues cd674aa115 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.
2026-07-31 12:58:32 -05:00
Brues f91ffd3189 bag: gate BAG_UPDATE_DELAYED on PLAYER_ENTERING_WORLD, not VAR_IN_WORLD
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).

Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
2026-07-30 20:13:05 -05:00
Brues 8361ae01eb equipment: unregister equip observers before re-registering on reload
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.

Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
2026-07-30 20:02:59 -05:00
Brues 5d785ea0d3 bag: hold BAG_UPDATE_DELAYED until in-world
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.

Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
2026-07-30 11:33:55 -05:00
Brues e84673dde5 item: clamp bank-bag slot count in NewItems baseline scan
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
2026-07-30 11:33:45 -05:00
Brues a2c5db7b76 time: add GetTimeCached() frame-stable timestamp
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.

Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
2026-07-30 03:12:07 -05:00
10 changed files with 398 additions and 70 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
@@ -66,7 +66,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
+84 -3
View File
@@ -294,6 +294,7 @@ build instructions.
- [`C_Loot.LootUnit(guid)`](#c_lootlootunitguid)
- [`C_Loot.LootUnitItem(guid, itemID)`](#c_lootlootunititemguid-itemid)
- [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
- [`C_Loot.LootAllCorpses([max])`](#c_lootlootallcorpsesmax)
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
@@ -494,6 +495,7 @@ build instructions.
- [Time](#time)
- [`GetServerTime()`](#getservertime)
- [`GetTimeCached()`](#gettimecached)
- [`C_Timer.After(seconds, callback)`](#c_timeraftersseconds-callback)
- [`C_Timer.NewTimer(seconds, callback)`](#c_timernewtimerseconds-callback)
- [`C_Timer.NewTicker(seconds, callback, [iterations])`](#c_timernewtickerseconds-callback-iterations)
@@ -2538,9 +2540,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")
@@ -7163,6 +7171,45 @@ Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a
single dropped server response doesn't hang the whole scan. Failed
corpses simply don't appear in the results.
### `C_Loot.LootAllCorpses([max])`
Loots **every** nearby lootable corpse in sequence — the same corpse
walk as [`ScanNearbyLoot`](#c_lootscannearbyloot), but it actually takes
the loot: coin (`CMSG_LOOT_MONEY`) plus every item slot (`CMSG_LOOT_ITEM`)
from each window before releasing and advancing to the next corpse. The
optional `max` caps how many corpses are visited (default: all in range).
Returns `true` if the loot walk was queued, `false` if a walk (this or
`ScanNearbyLoot`) is already in progress or the world isn't loaded — the
two share one state machine, so [`IsScanInProgress`](#c_lootisscaninprogress)
reports either, and only one runs at a time.
Like the scan, the walk is **silent**: `LOOT_OPENED` / `LOOT_CLOSED` are
suppressed so `LootFrame` never flickers open per corpse. The normal
item-received and money events still fire (`CHAT_MSG_LOOT`,
`CHAT_MSG_MONEY`, inventory updates), so the player sees what they got.
```lua
-- Loot everything within interact range
C_Loot.LootAllCorpses()
-- Loot at most 5 corpses
C_Loot.LootAllCorpses(5)
```
Completion is signaled by [`LOOT_SCAN_COMPLETED`](#loot_scan_completed-event);
afterward [`GetLastScanResults()`](#c_lootgetlastscanresults) reports what
each corpse held — i.e. what was looted.
> **Range and permissions.** Only corpses inside the engine's own
> right-click-loot range are queued (same distance test as
> `GetNearbyLootableUnits`), and only those the server flagged lootable
> for you. The item sends bypass the client-side BoP/unique **confirm
> dialog** (appropriate for a programmatic loot-all), but the server still
> enforces every real rule — loot rights, distance, master-loot. Unlike a
> right-click, this does **not** change your target or auto-walk you toward
> out-of-range corpses.
### `C_Loot.IsScanInProgress()`
Returns `true` while a `ScanNearbyLoot` call is mid-flight, `false`
@@ -11860,6 +11907,40 @@ right call for calendar / log-timestamp / cooldown-sync use cases.
> boundary and the timestamp is accurate to within a second of the
> engine's clock for as long as the session continues.
### `GetTimeCached()`
Returns the same value as [`GetTime()`](https://warcraft.wiki.gg/wiki/API_GetTime)
— seconds on the engine's uptime clock — but **frame-stable**: every call
within a single frame returns the identical value, refreshed once per
frame. Same epoch as `GetTime()`, so the two are directly comparable.
Backports the retail 4.x `GetTime()` behavior (retail samples the clock
once per frame in the main loop) under a distinct name, leaving vanilla's
`GetTime()` untouched — 1.12's `GetTime()` is *live*, recomputing the OS
tick on every call, so two same-frame `GetTime()` calls can differ.
```lua
-- Frame-stable: consistent timestamps across a frame's work
local now = GetTimeCached()
if now - lastFire >= interval then
lastFire = now
...
end
```
> **Not a performance feature.** The underlying tick source is cheap in
> 1.12 (`GetTickCount`, a user-mode shared-page read, or `rdtsc`), and
> both functions pay the same Lua→C call overhead, which dominates. So
> `GetTimeCached()` is not meaningfully faster than `GetTime()` — its
> only advantage is the frame-stable semantics. To actually cut cost in a
> hot `OnUpdate`, cache the value in a Lua local
> (`local now = GetTime()`) and reuse it; that removes the per-call Lua
> dispatch, which a cached C function cannot.
The cache is refreshed from the shared once-per-frame `Tick::WorldTick`
hook; before the first world tick (pre-login / glue) it falls back to a
live sample, so it never returns 0.
### `C_Timer.After(seconds, callback)`
Schedules `callback` to fire once after `seconds`. Returns nothing.
+35 -7
View File
@@ -3825,6 +3825,19 @@ 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
@@ -4875,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
@@ -5100,6 +5114,20 @@ enum Offsets {
// delayed crashes on unrelated vtable reads.
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
// `__fastcall(player)` — sends `CMSG_LOOT_MONEY` (the coin/money
// request) for the player's currently-open loot window. Vanilla has
// no `LootMoney` Lua global; coin is virtual loot slot 0 and the Lua
// `LootSlot` handler `FUN_004C2790` routes the coin slot here (its
// `param_1 == 0 && VAR_LOOT_LOOTABLE != 0` branch calls this with the
// resolved local player). Guards internally on the player's active
// loot GUID at `[player + 0x1D28]` — which `FUN_CMSG_LOOT_UNIT` writes
// when it sends the loot request — so it silently no-ops if no window
// is open. Pass the SAME player pointer used for `FUN_CMSG_LOOT_UNIT`
// so the `+0x1D28` guard it just wrote is the one read here.
// `Loot::Scan`'s `LootAllCorpses` path uses it to grab coin alongside
// the per-slot `CMSG_LOOT_ITEM` sends.
FUN_CMSG_LOOT_MONEY = 0x005EA8C0,
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
// server-driven dynamic-state bitmask. Empirically verified by
// cross-referencing three `Script_*` accessors that all read
+15
View File
@@ -60,6 +60,8 @@
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Bag::UpdateDelayed {
namespace {
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
void OnWorldTick() {
if (!g_pending)
return;
// Bag changes aren't meaningful until the player is actually in the world.
// During any loading screen (initial login, zone/instance transition) the
// engine re-populates bag slots as objects stream in (setting g_pending)
// while still behind the screen; the per-frame world tick runs there too,
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
// mid-load for no real change. Hold the pending flag — do NOT clear it —
// until the engine's in-world flag is set; the first in-world frame then
// emits a single DELAYED covering the settled inventory (matching retail's
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
// so this re-suppresses on later zone changes too, not just initial login.
if (*reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
+1 -3
View File
@@ -13,8 +13,6 @@
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
C_Loot.LootAllCorpses()
</Binding>
</Bindings>
+5 -1
View File
@@ -110,6 +110,8 @@ constexpr uint32_t kSettleMs = 1500;
// ~currently-owned items rather than everything ever owned.
constexpr int kMaxMissStreak = 4;
constexpr int kMaxContainerSlots = 64;
// One seen-set entry: an owned item's GUID and how many consecutive scans it
// has been absent from the read (0 = present this scan).
struct SeenEntry {
@@ -313,7 +315,9 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
if (bagSlots > kMaxContainerSlots)
bagSlots = kMaxContainerSlots; // guard against a garbage count at login
if (bagSlots > 0)
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
}
+123 -37
View File
@@ -8,16 +8,27 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
// the engine's loot-window response* before any visual updates happen.
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// Hook-driven loot corpse-walker. Walks the nearby-lootable-units set,
// opens each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then
// *intercepts the engine's loot-window response* before any visual updates
// happen. The intercept (`FUN_LOOT_CONTROLLER`) lets the original function
// run so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
// the engine's state machine cycles silently for each corpse and
// `LootFrame` never reacts. Other Lua addons listening to those events
// also see nothing during the scan; that's deliberate.
// `LOOT_CLOSED` event fires while the walk is in progress. Net result: the
// engine's state machine cycles silently for each corpse and `LootFrame`
// never reacts. Other Lua addons listening to those events also see nothing
// during the walk; that's deliberate.
//
// Two Lua entry points share this one walker (and, importantly, this one
// `FUN_LOOT_CONTROLLER` hook — MinHook allows a single hook per target, so
// they can't be separate modules):
// - `C_Loot.ScanNearbyLoot()` — scrape-only: read each corpse's contents
// into `GetLastScanResults()` and release without looting.
// - `C_Loot.LootAllCorpses([max])` — the same walk but actually LOOTS
// each window (coin via `CMSG_LOOT_MONEY`, every item slot via
// `CMSG_LOOT_ITEM`) before releasing. Silent (no LootFrame flicker);
// the usual item-received / money chat + inventory events still fire,
// since only the LootFrame open/close events are swallowed.
//
// Compared to the older polling-driven version this also collapses the
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
@@ -79,6 +90,7 @@ struct ScanCtx {
uint64_t currentGuid = 0;
int ticksSinceTransition = 0;
bool suppressEvents = false; // read by the FireEvent hook
bool lootMode = false; // LootAllCorpses (true) vs ScanNearbyLoot (false)
std::vector<uint64_t> queue;
std::vector<LootEntry> results;
};
@@ -106,6 +118,12 @@ using CloseLootInner_t = void(__fastcall *)(int sendRelease,
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
char *outBuf,
int bufSize);
// `FUN_CMSG_LOOT_ITEM` — `__stdcall(uint8_t wireSlot)`, sends CMSG_LOOT_ITEM
// for one slot of the open window (see the offset comment for the
// __stdcall-vs-__fastcall stack-corruption hazard).
using SendLootItem_t = void(__stdcall *)(uint8_t wireSlot);
// `FUN_CMSG_LOOT_MONEY` — `__fastcall(player)`, sends the coin request.
using LootMoney_t = void(__fastcall *)(void *player);
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
@@ -207,6 +225,42 @@ void ScrapeCurrentLoot(LootEntry *out) {
}
}
// Loots everything in the currently-open window: coin (if any) plus every
// populated item slot. Called from the controller hook (loot mode) after the
// original populated `VAR_LOOT_SLOTS`, so the slot table is fully filled in.
//
// Items go through `FUN_CMSG_LOOT_ITEM` directly — the same silent path
// `C_Loot.LootUnitItem` uses, which bypasses the BoP/unique confirm dialog
// the Lua `LootSlot` wrapper would raise (appropriate for a programmatic
// loot-all; the server still enforces real permissions). Coin goes through
// `FUN_CMSG_LOOT_MONEY` with `s_scan.player` — the SAME pointer we passed to
// `FUN_CMSG_LOOT_UNIT`, whose send wrote this window's target GUID to the
// player's `+0x1D28` guard slot, so the money send's guard passes.
//
// Packet order to the server is items, then coin, then the release that
// follows (`SendCloseLoot`); the server processes loot-item / loot-money
// before the release, so nothing is dropped.
void LootCurrentWindow() {
// Coin present when `VAR_LOOT_LOOTABLE` holds a real amount (0 = none;
// 0xFFFFFFFF = already-looted sentinel, never seen on a fresh open).
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
if (coinRaw != 0 && coinRaw != 0xFFFFFFFFu) {
auto LootMoney = reinterpret_cast<LootMoney_t>(Offsets::FUN_CMSG_LOOT_MONEY);
LootMoney(s_scan.player);
}
auto SendItem = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
if (itemID == 0)
continue;
SendItem(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
}
}
// === State-machine transitions ===
void StartLoot(uint64_t guid);
@@ -221,6 +275,7 @@ void Complete() {
s_scan.state = State::Idle;
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
s_scan.lootMode = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
}
@@ -315,6 +370,12 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
ScrapeCurrentLoot(&entry);
s_scan.results.push_back(std::move(entry));
// Loot mode: actually take everything (coin + all slots) before the
// release below. Scrape ran first, so `GetLastScanResults()` still
// reports what each corpse held.
if (s_scan.lootMode)
LootCurrentWindow();
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
// with `arg1 == 0`. That recursive call falls into the
// passthrough branch but still sees `suppressEvents == true`
@@ -377,46 +438,69 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
return 1;
}
void BuildQueue(void *player) {
void BuildQueue(void *player, size_t maxCount) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
// enumeration order, not distance-sorted, so the truncation is
// arbitrary among in-range corpses.
if (maxCount != 0 && s_scan.queue.size() > maxCount)
s_scan.queue.resize(maxCount);
}
// Shared starter for both entry points: resolve the player, build the
// corpse queue, kick off the walk. Returns whether it started — `false` if a
// walk is already in progress or there's no local player.
bool BeginWalk(bool lootMode, size_t maxCount) {
if (s_scan.state != State::Idle)
return false;
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return false;
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr)
return false;
s_scan.results.clear();
s_scan.player = player;
s_scan.lootMode = lootMode;
BuildQueue(player, maxCount);
if (s_scan.queue.empty()) {
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
return true;
}
TryStartNext();
return true;
}
// === Lua bindings ===
// `C_Loot.ScanNearbyLoot()` — scrape-only walk. Returns `true` if it
// started (a subsequent `GetLastScanResults()` holds the contents once
// `LOOT_SCAN_COMPLETED` fires), `false` if a walk is already running or
// there's no local player.
int __fastcall Script_ScanNearbyLoot(void *L) {
if (s_scan.state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/false, /*maxCount=*/0));
return 1;
}
s_scan.results.clear();
s_scan.player = player;
BuildQueue(player);
if (s_scan.queue.empty()) {
Complete();
Game::Lua::PushBool(L, true);
return 1;
}
TryStartNext();
Game::Lua::PushBool(L, true);
// `C_Loot.LootAllCorpses([max])` — loots every nearby lootable corpse
// (coin + all items) in sequence. Optional `max` caps how many corpses are
// visited. Returns `true` if it started, `false` if a walk (scan or loot)
// is already in progress or there's no local player. `LOOT_SCAN_COMPLETED`
// fires when the walk finishes; `GetLastScanResults()` then reports what
// each corpse held (i.e. what was looted).
int __fastcall Script_LootAllCorpses(void *L) {
const size_t maxCount = Game::Lua::IsNumber(L, 1)
? static_cast<size_t>(Game::Lua::ToNumber(L, 1))
: 0;
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/true, maxCount));
return 1;
}
@@ -462,6 +546,8 @@ int __fastcall Script_GetLastScanResults(void *L) {
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
&Script_ScanNearbyLoot);
Game::Lua::RegisterTableFunction("C_Loot", "LootAllCorpses",
&Script_LootAllCorpses);
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
&Script_IsScanInProgress);
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
+25 -5
View File
@@ -25,8 +25,10 @@
// mirroring the engine's registration shape exactly (bank 4, size 8). The
// dispatcher invokes us only when a slot's item GUID actually changed
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
// on our side, and the nodes share the engine observers' lifetime (they
// die with the player object; the setup re-runs each enter-world).
// on our side. The setup re-runs each enter-world; on logout/login the
// player object is recreated so old nodes die with it, but /reload REUSES
// the object, so we unregister-then-register per slot to avoid stacking a
// duplicate observer each reload (see ObserverSetup_h).
//
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
@@ -126,6 +128,10 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
using RegisterObserver_t = void(__fastcall *)(
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1, void *userArg2);
using UnregisterObserver_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi,
const void *callback,
void *userArg1);
using ObserverSetup_t = void(__fastcall *)();
ObserverSetup_t g_origSetup = nullptr;
@@ -147,12 +153,26 @@ void __fastcall ObserverSetup_h() {
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return;
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
auto reg = reinterpret_cast<RegisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_REGISTER);
auto unreg = reinterpret_cast<UnregisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_UNREGISTER);
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
const uint32_t field = Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8;
// Drop any observer we already registered for this field before adding
// a fresh one. The setup re-runs on every enter-world, including
// /reload — but /reload does NOT destroy the CGPlayer object, so our
// previous nodes are still attached and the registrar always APPENDS
// (no dedup by callback). Without this, each /reload stacks another
// observer per slot → PLAYER_EQUIPMENT_CHANGED fires N times after N
// reloads. On logout/login the object is recreated and the old nodes
// died with it, so the unregister no-ops there. Same objects-survive-
// /reload hazard `Unit::TokenObserver` guards against for nameplates.
unreg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
reinterpret_cast<const void *>(&EquipSlotChanged_cb), nullptr);
reg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
nullptr, nullptr);
}
+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, "");
+81
View File
@@ -0,0 +1,81 @@
// 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/>.
// `GetTimeCached()` — a frame-stable companion to vanilla `GetTime()`.
//
// 1.12's `GetTime()` is *live*: `Script_GetTime` (`0x00515ea0`) calls the
// OS tick source (`FUN_OS_TICKCOUNT_MS` → `GetTickCount`, or QPC in
// timing-mode 1) on every invocation and scales by 0.001, so two calls in
// the same frame can differ. Retail 4.x changed `GetTime()` to read a
// value sampled once per frame in the main loop, so all same-frame calls
// return the identical timestamp. Rather than change vanilla `GetTime`'s
// long-standing live behavior, we expose the frame-stable value under a
// new name and leave `GetTime()` alone.
//
// `GetTimeCached()` returns seconds on the SAME epoch as `GetTime()` (the
// OS millisecond counter × 0.001), so the two are directly comparable —
// the only difference is that `GetTimeCached()` holds constant for the
// duration of a frame. We refresh the cache from the shared once-per-frame
// `Tick::WorldTick` hook (which fires at the tail of each world frame).
#include "Game.h"
#include "Offsets.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Time::Cached {
namespace {
// OS millisecond counter sampled at the last frame boundary. 0 until the
// first world tick fires (pre-world). Read as uint32 — `GetTickCount`
// wraps at ~24.86 days, matching how the rest of the codebase treats this
// counter (see aura/Data.cpp), and how `Script_GetTime` masks it to 32
// bits before scaling.
uint32_t g_frameMs = 0;
uint32_t SampleTickMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Tail-of-frame refresh. Tail-of-frame-N ≈ start-of-frame-(N+1) minus the
// inter-frame gap, which is all a frame-stable timestamp needs: every read
// during a frame returns the value stamped at the previous boundary.
void OnWorldTick() {
g_frameMs = SampleTickMs();
}
// `GetTimeCached()` → number (seconds). Frame-stable analog of
// `GetTime()`, same epoch. Falls back to a live sample when no frame has
// ticked yet (pre-world / glue), so it never returns 0.
int __fastcall Script_GetTimeCached(void *L) {
uint32_t ms = g_frameMs;
if (ms == 0)
ms = SampleTickMs();
Game::Lua::PushNumber(L, static_cast<double>(ms) * 0.001);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTimeCached", &Script_GetTimeCached);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
} // namespace
} // namespace Time::Cached