Compare commits

...
26 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
Brues 46597f6587 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.
2026-07-30 00:45:00 -05:00
Brues 4e1c1863e9 debugtools: add Refresh button to TableInspector
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.

Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
2026-07-29 23:58:01 -05:00
Brues a4c57614bc debugtool: Use MouseIsOver global for fallback
WIthout ClassicAPI this will fallback to the 1.12.1 implementention of MouseIsOver
2026-07-29 13:14:13 -05:00
Brues e1a9d9b5c4 docs: document IsIndoors / IsOutdoors
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
2026-07-29 11:04:45 -05:00
Brues cd280ac307 state: add IsIndoors / IsOutdoors globals
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.

The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.

Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
2026-07-29 11:01:59 -05:00
Brues 564495248c item: apply random suffix in by-string GetItemNameByID / GetItemInfo
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.

Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.

GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).

Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
2026-07-28 16:33:50 -05:00
Brues 1f3f3cb9f6 aura: infer server-side DoT-duration changes from the triggering cast
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.

Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).

Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.

Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
  Conflagrate  -> reduce Immolate 3s
  Molten Blast -> refresh Flame Shock
  Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
    Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
    Turtle reworked it, so stock DBC proc masks don't apply -- match by
    school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).

Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
2026-07-28 16:14:45 -05:00
Brues 7ae9b83ad7 spell: CastAtUnit casts unit-target spells directly at the unit
Previously CastAtUnit only handled ground-target spells (placement at the
unit's feet); a non-ground spell fired with no implicit target (fell to
current selection). Now it resolves the unit once to its object, reads
position AND GUID, and dispatches the cast WITH that GUID -- the engine's
FUN_SPELL_CAST_DISPATCH substitutes it exactly where it would the current
selection (verified: passing 0 substitutes VAR_CURRENT_SELECTION_GUID), so
a unit-target/normal spell fires straight at that unit regardless of
current target.

Branch on placement state after dispatch: no placement -> cast went out
(unit-target); ground placement -> commit at the unit's feet (existing);
other placement -> unit not accepted as target, cancel + false.

AtCursor gains an optional targetGuid on DispatchSpellCast[ByName] (0 =
prior behavior, so CastAtCursor is unchanged) and IsPlacementActive().

Verified in-game: ground spells still land at the unit's feet; Frostbolt/
Renew/PW:Fortitude cast on the named unit while another unit stays targeted.
2026-07-27 23:36:03 -05:00
Brues 26e5fdc052 readme: place C_Glue helpers in the right sections
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
2026-07-27 22:18:52 -05:00
Brues 4281cd8253 glue: add C_Glue.IsFirstLoadThisSession and C_Glue.IsOnGlueScreen
IsFirstLoadThisSession() - glue-only; true only during the first glue
screen since process launch, false after a world->glue logout (login <->
character-select stays within the first session). Backed by a
process-static glue-boot counter incremented in the GlueModuleAutoRegister
callback (fires once per glue boot).

IsOnGlueScreen() - true on a GlueXML screen, false in the world.
Registered on both Lua states; since glue and in-game states are mutually
exclusive the answer is a constant per state (true from glue, false
in-game), so it's callable from either environment with no runtime flag.

Verified in-game: first login true/true, post-world-logout false; in-game
C_Glue.IsOnGlueScreen() false.
2026-07-27 22:12:28 -05:00
Brues 9239756153 frame: add frame:GetEffectiveAlpha()
Own alpha times every ancestor's alpha -- the product WoW composites at
render time. Vanilla has only GetAlpha (a frame's own alpha); this walks
self -> parent -> ... via the engine's own GetAlpha/GetParent, so it
reflects the engine exactly, including 8-bit alpha quantization
(SetAlpha(0.5) -> 127/255). Frame-scoped, matching GetAlpha's registry.

Verified in-game: matches an equivalent Lua GetAlpha/GetParent walk, and
a two-level 0.5/0.5 chain returns (127/255)^2 = 0.248043 exactly.
2026-07-27 21:40:16 -05:00
Brues f13aa1943d unit: add UnitPosition(unit)
Backport the modern UnitPosition -> posY, posX, posZ, instanceID. Reads
any visible unit's world position through the shared Unit::Position helper
(CGObject::GetPosition vtable slot 5 -- same path UnitDistanceSquared
uses), so no group/taint restriction like retail: works for target,
mouseover, nameplates, in-range party/raid. nil when the unit has no
known position.

Retail return order (posY=world Y/west, posX=world X/north, posZ=height);
instanceID is the current Map.dbc id. Verified in-game: squared delta of
two UnitPosition reads matches UnitDistanceSquared exactly.

Note: SuperWoW also defines a global UnitPosition with a different shape;
last registrant wins if both load. Documented in docs/API.md.
2026-07-27 21:19:58 -05:00
Brues 438ad97f8f readme: list IsMouseOver/GetRect/IsDragging in the Frame row 2026-07-27 20:59:20 -05:00
Brues 7f4b20c6ec frame: add region:GetRect and region:IsDragging
Two more Region-base methods backported from 3.3.5, both on the Region
registry so they resolve on every region type.

GetRect() -> left, bottom, width, height, composed from the engine's own
GetLeft/GetBottom/GetWidth/GetHeight (all UI-scale conversion is engine
code). Returns nothing for an unpositioned region, matching retail.

IsDragging() -> true while the region is the frame currently being
moved/sized. Reads the engine's single global drag target
(*(VAR_UI_CONTEXT_PTR) + 0xCFC); StopMovingOrSizing (0x00776990) gates its
own clear on the identical `context+0xCFC == self` predicate. Non-frame
regions never match the stored frame, so they report false.

Verified in-game: GetRect on UIParent/Minimap; IsDragging toggles with a
live frame drag.
2026-07-27 20:56:04 -05:00
Brues 3f11f942bd addon: use native region:IsMouseOver
Add a MouseIsOver(region, top, bottom, left, right) global that forwards
to the native Region:IsMouseOver method (mirrors modern FrameXML's
forwarding; nil offsets default to 0 in the C method).

Drop DebugTools' hand-rolled DebugTools_IsMouseOver helper in favor of
the native method. This also fixes _CollectFramesUnderMouse's showHidden
path: the old helper baked a visibility check into the geometric test, so
a hidden-but-hovered frame could never surface; the native method is
purely geometric and visibility is already gated separately at the call
site.
2026-07-27 20:38:10 -05:00
Brues 9622ea7742 frame: add region:IsMouseOver([top, bottom, left, right])
Native Region method (all region types) for cursor-in-rect testing with
optional per-edge offsets. Mirrors the engine's own math: delegates to
the Region-registry rect getters (GetLeft/Right/Top/Bottom) and
GetCursorPosition, dividing the cursor by the region's effective scale
read from obj+0x7C -- the same field GetLeft/GetEffectiveScale read
internally, so it works on textures/fontstrings where GetEffectiveScale
(Frame-only) would typecheck-fail. Returns false for an unpositioned
region. Offset semantics match modern FrameXML's MouseIsOver forwarding.
2026-07-27 20:30:37 -05:00
Brues 659dd7e159 newitems: fix false "new" flags from transient bag-scan drops
A bag update in progress transiently drops items from the pure-C++ bag
read -- sometimes the entire backpack (all 16 slots) for a frame or two.
The old logic rebuilt the seen-set from each scan, so a momentarily-
missing item fell out of the set and was re-flagged "new" the instant it
reappeared (e.g. dragging/swapping an item into the backpack lit it up).

Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
2026-07-27 20:13:49 -05:00
Brues 1ae102b554 add string.split to workaround certain addons replacing strsplit 2026-07-27 15:41:23 -05:00
33 changed files with 1959 additions and 199 deletions
+2
View File
@@ -42,3 +42,5 @@ Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
@@ -0,0 +1,70 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
-- off that trigger.
--
-- A trigger is matched by exact spellID (stable across ranks -- these are the
-- IDs the server binds its spell script to); the affected aura by
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
-- trigger is a whole class of spells rather than a named ability,
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
-- index instead (see Shadow Weaving below).
--
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
-- server-side probability roll on Ferocious Bite. The client can't know the
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
-- register it yourself, e.g. (DRUID = 7):
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
-- removal path, so the reduce rule below is harmless there and correct on
-- Turtle (where Immolate keeps ticking with 3s shaved off).
if TURTLE_WOW_VERSION then
local WARLOCK, SHAMAN = 5, 11
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
for i = 1, table.getn(CONFLAGRATE) do
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
end
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
for i = 1, table.getn(MOLTEN_BLAST) do
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
end
end
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
end
end)
+3
View File
@@ -0,0 +1,3 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
end
+3 -22
View File
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
end
end);
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
local function DebugTools_IsMouseOver(frame)
if (not frame) then return false; end
local visOK, visible = pcall(frame.IsVisible, frame);
if (not visOK or not visible) then return false; end
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
if (not sOK or not scale or scale == 0) then return false; end
local cx, cy = GetCursorPosition();
cx = cx / scale;
cy = cy / scale;
local lOK, left = pcall(frame.GetLeft, frame);
local rOK, right = pcall(frame.GetRight, frame);
local tOK, top = pcall(frame.GetTop, frame);
local bOK, bottom = pcall(frame.GetBottom, frame);
if (not (lOK and rOK and tOK and bOK)) then return false; end
if (not (left and right and top and bottom)) then return false; end
return cx >= left and cx <= right and cy >= bottom and cy <= top;
end
local _EventTraceFrame;
_framesSinceLast = 0;
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
button:UnlockHighlight();
end
if (DebugTools_IsMouseOver(button)) then
if MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local mouseOver = DebugTools_IsMouseOver(frame);
if (mouseOver and (showHidden or (visOK and visible))) then
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
local nameOK, name = pcall(frame.GetName, frame);
if (not nameOK or not name) then name = "<anonymous>"; end
table.insert(matches, { name = name, frame = frame });
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+8 -6
View File
@@ -35,16 +35,17 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [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` |
@@ -60,17 +61,17 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [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` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -90,6 +91,7 @@ the glue state because GlueXML had no way to reach them otherwise.
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
+424 -27
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)
@@ -155,10 +156,14 @@ build instructions.
- [Frame](#frame)
- [`region:SetPoint("point")` (one-argument form)](#regionsetpointpoint-one-argument-form)
- [`region:SetSize(width, height)` / `region:GetSize()`](#regionsetsizewidth-height--regiongetsize)
- [`region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])`](#regionismouseovertopoffset-bottomoffset-leftoffset-rightoffset)
- [`region:GetRect()`](#regiongetrect)
- [`region:IsDragging()`](#regionisdragging)
- [`frame:SetShown(shown)`](#framesetshownshown)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
- [`frame:IsEventRegistered(event)`](#frameiseventregisteredevent)
- [`frame:GetEffectiveAlpha()`](#framegeteffectivealpha)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
@@ -198,6 +203,10 @@ build instructions.
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [Glue](#glue)
- [`C_Glue.IsFirstLoadThisSession()`](#c_glueisfirstloadthissession)
- [`C_Glue.IsOnGlueScreen()`](#c_glueisongluescreen)
- [Gossip](#gossip)
- [`C_GossipInfo.GetText()`](#c_gossipinfogettext)
- [`C_GossipInfo.GetOptions()`](#c_gossipinfogetoptions)
@@ -285,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)
@@ -451,6 +461,8 @@ build instructions.
- [`IsStealthed()`](#isstealthed)
- [`IsFalling()`](#isfalling)
- [`IsSwimming()`](#isswimming)
- [`IsIndoors()`](#isindoors)
- [`IsOutdoors()`](#isoutdoors)
- [`IsAssistingRitual()`](#isassistingritual)
- [`IsInGroup()`](#isingroup)
- [`IsInRaid()`](#isinraid)
@@ -483,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)
@@ -529,6 +542,7 @@ build instructions.
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitPosition(unit)`](#unitpositionunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`ClosestUnitPosition(creatureID)`](#closestunitpositioncreatureid)
- [`UnitHealthMissing(unit)`](#unithealthmissingunit)
@@ -544,6 +558,7 @@ build instructions.
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
- [`C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`](#c_unitaurasregistercombodurationspellid-baseseconds-maxseconds)
- [`C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`](#c_unitaurasregisterauradurationmodifiertriggerspellid-affectedfamily-affectedfamilyflags-affectedicon-op--valueseconds)
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
@@ -2525,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")
@@ -3111,6 +3132,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
@@ -3593,6 +3668,52 @@ The modern combined setter/getter for `SetWidth`+`SetHeight` /
textures (including engine-created ones like
`button:GetNormalTexture()`), fontstrings.
### `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])`
`true` when the mouse cursor is within the region's rectangle, `false`
otherwise. The four optional offsets shift the corresponding edge before
the test (added to that edge, matching modern semantics: positive
`topOffset` / `rightOffset` and negative `bottomOffset` / `leftOffset`
enlarge the hit area). Registered on the Region base, so it works on any
region type — frames, buttons, textures, fontstrings.
Uses the engine's own rect getters and cursor position, dividing the
cursor by the region's effective scale — the exact computation vanilla
addons have long open-coded as `MouseIsOver()`. Returns `false` for a
region with no resolved rect (unpositioned / zero-size).
```lua
ItemRefTooltip:IsMouseOver() -- true while hovering the tooltip
Minimap:IsMouseOver(20, -20, -20, 20) -- true within a 20px halo around it
```
### `region:GetRect()`
Returns the region's `left, bottom, width, height` in one call (the
modern combined form of `GetLeft`+`GetBottom`+`GetWidth`+`GetHeight`).
Composed from the engine's own getters, so all UI-scale conversion is its
code. On the Region base — works on any region type. Returns nothing for
a region with no resolved rect (unpositioned), matching retail.
```lua
UIParent:GetRect() -- 0, 0, <screenWidth>, <screenHeight>
Minimap:GetRect() -- <left>, <bottom>, 140, 140
```
### `region:IsDragging()`
`true` while the region is the frame currently being moved or resized by
the mouse — i.e. after `StartMoving`/`StartSizing` and before
`StopMovingOrSizing` — `false` otherwise. Reads the engine's single global
drag target (the same slot `StopMovingOrSizing` checks to decide whether
it owns the active drag). On the Region base; non-frame regions (textures,
fontstrings) always report `false`, since only frames can be dragged.
```lua
-- in a frame's OnUpdate while the user drags it:
if self:IsDragging() then ... end
```
### `frame:SetShown(shown)`
`Show()` if `shown` is truthy, `Hide()` otherwise. Registered for
@@ -3641,6 +3762,22 @@ f:IsEventRegistered("PLAYER_LOGIN") -- true
f:IsEventRegistered("PLAYER_LOGOUT") -- false
```
### `frame:GetEffectiveAlpha()`
Returns the frame's **effective** alpha — its own `GetAlpha()` multiplied
by every ancestor's, i.e. the opacity WoW actually composites at render
time (fading `UIParent` fades all of its children). Vanilla exposes only
`GetAlpha` (a frame's own alpha); this walks `self → parent → …` via the
engine's own `GetAlpha`/`GetParent`, so it sees exactly what the engine
does — including the 8-bit quantization of alpha (`SetAlpha(0.5)` stores
`127/255 ≈ 0.498`, and effective alpha is the product of those).
```lua
UIParent:GetEffectiveAlpha() -- 1
-- with UIParent at 0.5 and Minimap's own alpha 1:
Minimap:GetEffectiveAlpha() -- ~0.498 (0.5 truncates to 127/255)
```
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -4641,6 +4778,47 @@ not modern's `ComboPoints` reuse of the same number.
local rage = UnitPower("player", Enum.PowerType.Rage)
```
## Glue
Backports of two `C_Glue` session helpers. `C_Glue` is registered on the glue
(login / character-select) Lua state; `IsOnGlueScreen` is *also* registered
in-game, so code that runs in either environment can branch on it. Neither
needs an engine flag — both answers fall out of which glue boot / Lua state
is asking.
### `C_Glue.IsFirstLoadThisSession()`
Returns `true` only while the **first** glue screen since the process
launched is showing, `false` on every later return to the glue screen. Login
↔ character-select transitions stay within the first glue session (they never
enter the world), so it stays `true` across them — it flips `false` only once
you've entered the world and logged back out. Glue state only.
```lua
if C_Glue.IsFirstLoadThisSession() then
-- one-time-per-launch startup (intro cinematic, news, …)
end
```
Backed by a process-static count of glue boots (the glue script-registration
pass fires once per boot: initial launch + each world→glue logout); boot 1 is
the first load.
### `C_Glue.IsOnGlueScreen()`
Returns `true` when a GlueXML screen is showing (login or character select —
no character in the world), `false` in the world. Registered on **both** Lua
states, so it's callable from glue and in-game code alike. The two states are
mutually exclusive (the glue state exists only when no character is in the
world), so the answer is fixed per state — `true` from the glue registration,
`false` from the in-game one.
```lua
if C_Glue.IsOnGlueScreen() then
-- at login / character select
end
```
## Gossip
Retail-shaped wrappers around vanilla's flat `GetGossipText` /
@@ -6993,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`
@@ -10474,34 +10691,47 @@ the item-use path for grenades / on-use ground-target items.
### `C_Spell.CastAtUnit(spellIDOrName, unit)`
Casts a ground-target spell at `unit`'s feet, bypassing the AoE
reticle click. ClassicAPI's analog of modern's
`/cast [@unit] Flamestrike` — `CastAtUnit(spell, "player")` drops it
at the player's position, `"target"` at the target's, and so on for
any unit token (`"mouseover"`, `"party1"`, `"raid7"`, …). Returns
`true` when the placement landed at the unit; `false` for
non-ground-target spells (cast still fires normally), unknown
spells, or a unit with no resolvable position.
Casts a spell **at `unit`, whatever its target type** — ClassicAPI's
analog of modern's `/cast [@unit] Spell`:
- **Ground-target spells** (Flamestrike, Blizzard, Rain of Fire, …) are
placed at the unit's feet, bypassing the AoE reticle click.
- **Unit-target / normal spells** (Frostbolt, Renew, a buff, …) fire
directly on the unit — regardless of your current selection, so you can
buff `"party1"` or heal `"player"` while an enemy stays targeted.
Works with any unit token (`"target"`, `"player"`, `"mouseover"`,
`"party1"`, `"raid7"`, …). Returns `true` when the spell was cast at the
unit (fired or placed); `false` for a spell not in your spellbook, a unit
with no resolvable position, or a unit the engine won't accept as a target
for that spell (wrong faction, out of range).
The first argument takes a spellID or a spell name, with the same
exact-rank / `(Rank N)` semantics as `CastAtCursor`.
```lua
-- ground-target: dropped at the unit's feet
C_Spell.CastAtUnit(2120, "target") -- Flamestrike at the target's feet
C_Spell.CastAtUnit(10, "player") -- Blizzard Rank 1 on yourself
C_Spell.CastAtUnit("Blizzard(Rank 6)", "target")
-- unit-target / normal: cast straight on the unit, ignoring current target
C_Spell.CastAtUnit("Frostbolt", "target")
C_Spell.CastAtUnit("Renew", "player") -- heal yourself mid-fight
C_Spell.CastAtUnit("Power Word: Fortitude", "party1")
```
Same two-stage shape as
[`C_Spell.CastAtCursor`](#c_spellcastatcursorspellidorname), but instead of
the cursor raycast, step 2 reads the unit's world position from its
`GetPosition` virtual (the same one `CheckInteractDistance` /
`UnitInRange` use) and commits placement there via
`FUN_COMMIT_PLACEMENT_COORDS`. The unit is resolved *before* the cast
is dispatched, so an absent/invalid unit fails cleanly without
leaving the engine armed in placement mode. A genuinely unrecognized
unit-token string raises the engine's standard "Unknown unit" error,
matching `UnitHealth("garbage")`.
The unit is resolved once to its object; the cast is dispatched with the
unit's **GUID** as the implicit target (the engine substitutes it exactly
where it would the current selection), so a unit-target spell fires
immediately at that unit. A ground spell ignores the GUID and enters
placement mode instead, which is then committed at the unit's world
position (read from its `GetPosition` virtual — the same one
`CheckInteractDistance` / `UnitInRange` use) via
`FUN_COMMIT_PLACEMENT_COORDS`. The engine can't be left armed in placement
mode on failure: if no placement is active after dispatch the cast already
went out, and a non-ground placement (an unaccepted unit target) is
cancelled. A genuinely unrecognized unit-token string raises the engine's
standard "Unknown unit" error, matching `UnitHealth("garbage")`.
The companion item version is
[`C_Item.UseAtUnit`](#c_itemuseatunititeminfo-unit).
@@ -10974,6 +11204,37 @@ if IsSwimming() then
end
```
### `IsIndoors()`
Returns `1` if the local player is under a WMO roof — inside a building,
cave, or instance interior — and `nil` otherwise. `nil` before the player
object exists (pre-world).
Recomputed live from the engine's WMO geometry query, so it flips as you
cross an interior threshold rather than tracking the current zone/area —
you can be indoors and outdoors within the same subzone. Returns `1`/`nil`
(not `true`/`false`) to match the historical retail contract and
SuperWoW 2.2, so both `if IsIndoors()` and `IsIndoors() == 1` work.
```lua
if IsIndoors() then
-- suppress a mount cast that would just fail
end
```
### `IsOutdoors()`
Returns `1` if the local player is outdoors (open sky, or simply not
inside a WMO interior) and `nil` otherwise. Exact complement of
[`IsIndoors()`](#isindoors) for a resolvable player; both return `nil`
pre-world.
```lua
if IsOutdoors() then
CastSpellByName("Mount Up")
end
```
### `IsAssistingRitual()`
Returns `true` if the local player is currently clicking a warlock
@@ -11646,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.
@@ -12484,17 +12779,58 @@ human.
Reads world positions via the `CGObject::GetPosition` vtable virtual
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
which is left untouched).
`CheckInteractDistance` and our own [`UnitPosition`](#unitpositionunit)
use. **Center-to-center**, not reach-aware (that edge-to-edge nicety is
UnitXP_SP3's niche); the value equals what you'd compute from the raw
`UnitPosition` coordinates, and it's self-contained (no sibling-DLL
dependency).
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitPosition(unit)`
Returns `posY, posX, posZ, instanceID` — the unit's world position, in
the modern (WoD+) `UnitPosition` shape. `nil` when the unit has no known
position.
```lua
local posY, posX, posZ, instanceID = UnitPosition("player")
```
| Return | Meaning |
|--------|---------|
| `posY` | World Y coordinate (the west axis). |
| `posX` | World X coordinate (the north axis). |
| `posZ` | World Z coordinate (height). |
| `instanceID` | Currently-loaded map id (`Map.dbc` row — e.g. `0` Eastern Kingdoms, `1` Kalimdor). Every visible unit shares the player's instance. |
**Order matches retail** — `posY` (west) first, then `posX` (north), then
`posZ`, mirroring Blizzard's own `UnitPosition`. So an addon written for
retail that does `local py, px = UnitPosition(u)` gets its coordinates
labelled as expected. (World X is north/south, Y is west/east — WoW's
long-standing convention.)
**Not group-restricted.** Retail returns `nil` for units outside your
party/raid (a privacy guard that doesn't exist in 1.12). This backport
reads any *visible* unit — `"target"`, `"mouseover"`, nameplate tokens,
arbitrary party/raid members in sync range — so it's strictly more
permissive, closer to SuperWoW's unit-position access. Units the client
can't currently see (out of range, another zone) have no position → `nil`.
Reads through the same `CGObject::GetPosition` virtual as
[`UnitDistanceSquared`](#unitdistancesquaredunit); the two are consistent
(the squared delta of two `UnitPosition` reads equals
`UnitDistanceSquared`).
> **SuperWoW interaction.** SuperWoW also defines a global `UnitPosition`
> with a different return shape. Both register on the same Lua state, so
> if SuperWoW is loaded the last registrant wins — install order decides
> which shape is live. Without SuperWoW, this retail-shaped version is the
> one you get.
### `UnitInLineOfSight(unit)`
Returns `true` if the player has clear line of sight to `unit`, `false`
@@ -13008,6 +13344,67 @@ is the escape hatch for *other* custom servers with the same class of
gap — call it from your own config or addon; a registered override
beats both the DBC and the built-in values.
### `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`
ClassicAPI extension for **server-side DoT-duration changes on another
unit that the client is never told about**. Returns `true` on success.
`expirationTime` for a debuff on another unit is computed from the cast
packet (`SMSG_SPELL_GO`) at cast time. When the server later changes that
debuff's remaining duration — a refresh, or a partial consume — 1.12 sends
**no** packet an observing caster can see (verified in the server source:
the change runs through `SetAuraDuration`/`RefreshHolder`, and the only
duration packet is self-scoped to the aura-bearer; on a mob it isn't even
built). So the cached `expirationTime` would go stale.
The client *does* always see the **triggering** cast, though. Register a
rule and ClassicAPI mirrors the server's edit on the cached aura when that
trigger lands:
| Arg | Meaning |
|-----|---------|
| `triggerSpellID` | The spell whose cast triggers the change (match one rank per call — use the exact IDs the server binds). |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `7` druid). |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
| `valueSeconds` | Amount for `reduce`/`set`; ignored otherwise. |
The rule only fires for the aura **cast by the same unit** as the trigger
(these mechanics act on the caster's own DoT), and misses are excluded for
free (a missed trigger isn't in the packet's hit list).
**`C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`**
is the same, but matches the *trigger* by `SpellFamilyName` + school index
(`0` physical … `2` fire, `5` shadow, `6` arcane; `< 0` = any) instead of an
exact spellID — one rule covers a whole class of spells (every rank, plus
server-added ones). Use it when the trigger is a category rather than a named
ability.
`!!!ClassicAPI` ships a default Turtle ruleset
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua)):
Conflagrate shaves 3s off the caster's Immolate, Molten Blast refreshes the
caster's Flame Shock, and — for a 5/5 Shadow Weaving priest (talent `15334`,
where the proc is 100%) — any shadow-school priest cast refreshes the target's
Shadow Vulnerability (`15258`). Deliberately excluded: probabilistic effects
(Carnage's roll-gated Rip/Rake refresh, and Shadow Weaving below 5/5) — the
client can't see the server's roll, so inferring them would show wrong timers.
Two accepted best-effort caveats on the Shadow Weaving rule: DoT *ticks*
(SW:P / Devouring Plague) also refresh it server-side but emit no cast packet,
so a pure DoT-only phase under-counts slightly; and a full-consume Conflagrate
variant on other servers *removes* Immolate, which ClassicAPI already evicts
via the normal removal path (so the reduce rule is harmless there).
```lua
-- Flags are hex; Lua 5.0 has no 0x literals, so use tonumber(hex, 16).
-- Conflagrate (Rank 4) shaves 3s off the caster's Immolate (warlock, flag 0x4):
C_UnitAuras.RegisterAuraDurationModifier(18932, 5, tonumber("4", 16), 0, "reduce", 3)
-- Molten Blast refreshes the caster's Flame Shock (shaman, flag 0x10000000):
C_UnitAuras.RegisterAuraDurationModifier(36916, 11, tonumber("10000000", 16), 0, "refresh")
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability (15258):
C_UnitAuras.RegisterAuraDurationModifierByTrigger(6, 5, 6, tonumber("4000000", 16), 9, "refresh")
```
### `C_UnitAuras.GetUnitAuras(unit [, filter])`
Returns a numerically-indexed array of `AuraData` tables for every
+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 /
+118 -7
View File
@@ -316,6 +316,16 @@ enum Offsets {
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Inner name formatter behind FUN_ITEM_BUILD_INSTANCE_NAME, callable
// WITHOUT a CGItem: `__fastcall(char *out /*ecx*/, uint outSize /*edx*/,
// uint32 itemID, int suffixID)`. Fetches the ItemStats record for itemID
// and, when suffixID is a valid ItemRandomProperties row with a localized
// suffix name (record +0x1c + locale*4), formats base + suffix via
// ITEM_SUFFIX_TEMPLATE; otherwise the base name. Lets the by-STRING item
// paths (GetItemNameByID, C_Item.GetItemInfo) apply an item link's random
// suffix without a live item instance.
FUN_ITEM_BUILD_NAME_FROM_ID = 0x005D8B00,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -544,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
@@ -1314,12 +1349,42 @@ enum Offsets {
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
// Region rect getters (return a number, or nil when the region has no
// resolved rect). All on the REGION base registry → callable on any
// region type. Used by Frame::Modern's IsMouseOver.
FUN_SCRIPT_REGION_GETLEFT = 0x007A1980,
FUN_SCRIPT_REGION_GETRIGHT = 0x007A1AA0,
FUN_SCRIPT_REGION_GETTOP = 0x007A1BC0,
FUN_SCRIPT_REGION_GETBOTTOM = 0x007A1CE0,
// In-game GetCursorPosition (0x0046DAD0 is the glue-state copy). Pushes
// (x, y) in the same k = FUN_0041ad70()*DAT_007ffd68 space the region
// rect getters use — so dividing by the region's effective scale puts
// the cursor in the region's logical coordinate space.
FUN_SCRIPT_GETCURSORPOSITION = 0x0048B820,
// Region effective-scale field: `piVar[0x1f]` (float) read by both
// GetEffectiveScale (0x00774FC0) and internally by GetLeft/Top/etc. —
// present on every region object, so it's the all-region way to get the
// effective scale without the Frame-only GetEffectiveScale method.
OFF_REGION_EFFECTIVE_SCALE = 0x7C,
// Global UI/screen context pointer (every frame's +0xA0 field mirrors
// it; GetCursorPosition reads the cursor from *ptr + 0x1118). Its
// +0xCFC slot holds the frame currently being moved/sized via
// StartMoving/StartSizing (0 when idle) — the drag target. Backs
// Frame::Modern's IsDragging: StopMovingOrSizing (0x00776990) gates its
// clear on `*(context+0xCFC) == self`, i.e. the IsDragging predicate.
VAR_UI_CONTEXT_PTR = 0x00CF0BD8,
OFF_UI_CONTEXT_DRAG_TARGET = 0xCFC,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
// Frame GetAlpha (own alpha, 0..1) + Region GetParent — walked by
// Frame::Modern's GetEffectiveAlpha up the parent chain.
FUN_SCRIPT_FRAME_GETALPHA = 0x00774DC0,
FUN_SCRIPT_REGION_GETPARENT = 0x007A1460,
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
@@ -3760,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
@@ -4674,6 +4752,23 @@ enum Offsets {
// `castGameObject != 0`).
OFF_CGPLAYER_CAST_GAMEOBJECT_PTR = 0xB4,
// Indoor/outdoor query for a unit — backs `IsIndoors()` / `IsOutdoors()`
// (`unit/State.cpp`). `__fastcall(unit) -> uint` where **nonzero =
// outdoors**, 0 = indoors. A 2-instruction thunk: reads the unit's
// world/collision-context pointer at `unit + 0xE0` and tail-jumps to the
// WMO query `FUN_006706f0`. That query is fully null-safe — null context
// or a unit not inside a WMO group returns 1 (outdoors); otherwise it
// resolves the current WMO group via `FUN_0069d4f0` and returns bit 15
// (`0x8000`) of the group's runtime flags word (the OUTDOOR bit).
//
// Derived from SuperWoW 2.2's `IsIndoors` handler (which does exactly
// `unit = ObjMgr(0x10, PlayerGUID()); push (query(unit)==0)`), then
// confirmed against the vanilla binary. Vanilla ships no Lua binding for
// this; the 3.3.5 client's `IsIndoors` (`0x00612300`) uses the identical
// shape (`unit + 0xB8` → WMO group flag test), so the semantics are
// cross-checked across both clients.
FUN_UNIT_IS_OUTDOORS = 0x00612cc0,
// No `MOVEFLAG_FLYING` constant intentionally. Vanilla 1.12
// doesn't flip a flying bit during taxi flights (verified
// empirically: `IsFlying()` checking 0x01000000 stayed false
@@ -4793,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
@@ -5018,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
@@ -5387,6 +5497,7 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ICON_ID = 0x1D4, // u32 SpellIconID (→ SpellIcon.dbc)
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
+263
View File
@@ -27,6 +27,7 @@
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace Aura::Source {
@@ -234,6 +235,263 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
//
// Some server mechanics change a DoT's remaining time on another unit with
// NO client-visible signal — verified in tortoise-wow: the change goes
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules come from Lua
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
// lives in the addon, not the DLL. Turtle examples:
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched by exact spellID (from the server's script binding,
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
// deliberately NOT shipped as defaults — the client can't know the server's
// roll outcome, so inferring them would show wrong timers.
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
struct DurationMod {
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
uint32_t affectedFamily; // SpellFamilyName of the affected aura
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
int32_t op;
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
};
constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
return false;
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if ((flags & m.affectedMask) == 0)
return false;
if (m.affectedIcon != 0 &&
*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
return false;
return true;
}
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
// by the cast spell's SpellFamilyName + school index — one rule then covers
// every rank / server-added spell of a class's school (e.g. any priest
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
if (m.triggerSpellId != 0)
return m.triggerSpellId == triggerSpellId;
if (triggerRec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
return false;
if (m.triggerSchool >= 0 &&
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
return false;
return true;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
case MOD_REFRESH:
if (e.durationMs > 0)
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
else
e.used = false; // shaved to/past now → server removes it
}
break;
case MOD_REMOVE:
e.used = false;
break;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
// own, so they'd otherwise be dropped.
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
const uint64_t *targets, int numTargets) {
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
return;
const uint8_t *triggerRec =
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
const uint32_t now = NowMs();
for (int r = 0; r < g_modCount; ++r) {
const DurationMod &m = g_mods[r];
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
// but PROC-applied ones (Shadow Weaving) enter the cache via the
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
// caster), so accept those too and attribute them to this trigger
// caster (also gives them a sourceUnit).
if (e.casterGuid != 0 && e.casterGuid != caster)
continue;
if (!AffectedMatches(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
continue;
if (e.casterGuid == 0)
e.casterGuid = caster;
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
}
}
}
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
DurationMod &m = g_mods[i];
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
m.op = op;
m.valueMs = valueMs;
return true;
}
}
if (g_modCount >= kMaxMods)
return false;
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
affectedFamily, affectedMask, affectedIcon, op, valueMs};
return true;
}
int OpFromString(const char *s) {
if (s == nullptr)
return -1;
if (_stricmp(s, "refresh") == 0)
return MOD_REFRESH;
if (_stricmp(s, "reduce") == 0)
return MOD_REDUCE;
if (_stricmp(s, "set") == 0)
return MOD_SET;
if (_stricmp(s, "remove") == 0)
return MOD_REMOVE;
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
// (trigger, family, flags, icon) tuple replaces its op/value.
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsString(L, 5)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
"valueSeconds])");
return 0;
}
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
const int op = OpFromString(Game::Lua::ToString(L, 5));
const int32_t valueMs =
Game::Lua::IsNumber(L, 6)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
: 0;
if (op < 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, family, mask,
icon, op, valueMs));
return 1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
// by SpellFamilyName + school index instead of an exact spellID — one rule
// covers every rank / server-added spell of a class's school. triggerSchool
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
// refresh the target's Shadow Vulnerability.
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
"affectedIcon, op[, valueSeconds])");
return 0;
}
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
const int op = OpFromString(Game::Lua::ToString(L, 6));
const int32_t valueMs =
Game::Lua::IsNumber(L, 7)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
: 0;
if (op < 0 || tfamily == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
family, mask, icon, op, valueMs));
return 1;
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
&Script_RegisterAuraDurationModifier);
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
@@ -328,6 +586,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (caster == Unit::Identity::PlayerGuid())
Totem::Tracker::OnPlayerSpellGo(spellId);
// Mirror server-side duration edits the client is never told about
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
+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
View File
@@ -265,6 +265,7 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
+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>
+153
View File
@@ -26,6 +26,12 @@
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `region:IsMouseOver([top, bottom, left, right])` — cursor-in-rect
// test with optional per-edge offsets, also on the REGION registry.
// - `region:GetRect()` — left, bottom, width, height, composed from the
// engine's Region getters. Also on the REGION registry.
// - `region:IsDragging()` — true while the region is the frame currently
// being moved/sized. Reads the engine's global drag target.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
@@ -34,6 +40,9 @@
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
// - `frame:GetEffectiveAlpha()` — own alpha × the parent chain's, the
// product WoW composites at render time. Frame-scoped (matches
// GetAlpha); walks the parents via the engine's own getters.
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
@@ -83,6 +92,119 @@ int __fastcall Script_GetSize(void *L) {
return 2;
}
// ---- IsMouseOver (Region base — all region types) --------------------------
// `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])` —
// true when the cursor is within the region's rect, each edge optionally
// shifted by the matching offset (added to that edge, modern semantics:
// +top/-bottom/-left/+right enlarges). Vanilla addons have always done this
// in Lua as MouseIsOver(); we register it as the native Region method later
// clients expose.
//
// The math mirrors the engine exactly: GetLeft/Right/Top/Bottom (Region
// registry, all types) and GetCursorPosition both return values scaled by the
// same k = FUN_0041ad70()*DAT_007ffd68 factor, but the rect getters also
// divide by the region's effective scale — so dividing the cursor by that same
// effective scale (read from obj+0x7C, the field GetLeft/GetEffectiveScale
// themselves read) puts both in the region's logical space. A getter returns
// nil for an unpositioned region → we report false.
// Calls a Region getter that returns one number; false if it pushed nil/none.
bool CallRegionNumber(void *L, uintptr_t fn, double *out) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(fn, L); // (self, value | nil)
if (!Game::Lua::IsNumber(L, 2))
return false;
*out = Game::Lua::ToNumber(L, 2);
return true;
}
int __fastcall Script_IsMouseOver(void *L) {
// Offsets first — the stack gets reshaped by the delegated getters.
const double offTop = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0.0;
const double offBottom = Game::Lua::IsNumber(L, 3) ? Game::Lua::ToNumber(L, 3) : 0.0;
const double offLeft = Game::Lua::IsNumber(L, 4) ? Game::Lua::ToNumber(L, 4) : 0.0;
const double offRight = Game::Lua::IsNumber(L, 5) ? Game::Lua::ToNumber(L, 5) : 0.0;
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
double left, right, top, bottom;
if (effScale == 0.0f ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom)) {
Game::Lua::PushBool(L, false); // no resolved rect
return 1;
}
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
const double x = Game::Lua::ToNumber(L, 1) / effScale;
const double y = Game::Lua::ToNumber(L, 2) / effScale;
left += offLeft;
right += offRight;
top += offTop;
bottom += offBottom;
const bool inside = x >= left && x <= right && y >= bottom && y <= top;
Game::Lua::PushBool(L, inside);
return 1;
}
// ---- GetRect (Region base — all region types) ------------------------------
// `region:GetRect()` → left, bottom, width, height. Composed from the
// engine's own Region getters (so all UI-scale conversion is its code).
// Matches retail: returns nothing for a region with no resolved rect —
// gated on GetLeft, which pushes nil when the position isn't resolved (a
// size-only region with no anchor has width/height but no rect).
int __fastcall Script_GetRect(void *L) {
double left, bottom, width, height;
if (!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left))
return 0;
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETWIDTH, &width);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETHEIGHT, &height);
Game::Lua::SetTop(L, 1);
Game::Lua::PushNumber(L, left);
Game::Lua::PushNumber(L, bottom);
Game::Lua::PushNumber(L, width);
Game::Lua::PushNumber(L, height);
return 4;
}
// ---- IsDragging (Region base — all region types) ---------------------------
// `region:IsDragging()` — true while this region is the frame currently
// being moved/sized (StartMoving/StartSizing active, before
// StopMovingOrSizing). The engine tracks a single drag target in the global
// UI context at +0xCFC; StopMovingOrSizing itself gates on
// `context+0xCFC == self`, which is exactly this predicate. Reading the
// global context (rather than self+0xA0) keeps it correct for non-frame
// regions — a texture/fontstring self can never match the frame stored
// there, so it reports false, as it should.
int __fastcall Script_IsDragging(void *L) {
void *self = Game::Lua::ResolveObject(L, 1);
void *ctx = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *dragTarget =
ctx != nullptr
? *reinterpret_cast<void *const *>(
reinterpret_cast<const uint8_t *>(ctx) +
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
: nullptr;
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
return 1;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
@@ -95,6 +217,33 @@ int __fastcall Script_SetShown(void *L) {
return 0;
}
// ---- GetEffectiveAlpha (Frame) ---------------------------------------------
// `frame:GetEffectiveAlpha()` — the frame's own alpha times every
// ancestor's alpha, i.e. the product WoW composites at render time (fading
// UIParent fades all children). Vanilla exposes only GetAlpha (own alpha);
// we walk self → parent → … via the engine's own GetAlpha (Frame) and
// GetParent (Region), so no field offsets or scale math are re-derived. The
// iteration cap is a defensive guard against a pathological chain (WoW frame
// trees are shallow; the loop normally ends when GetParent returns nil at
// the top of the tree).
int __fastcall Script_GetEffectiveAlpha(void *L) {
Game::Lua::SetTop(L, 1); // (obj) = self
double eff = 1.0;
for (int guard = 0; guard < 128; ++guard) {
CallScript(Offsets::FUN_SCRIPT_FRAME_GETALPHA, L); // (obj, alpha)
eff *= Game::Lua::ToNumber(L, 2);
Game::Lua::SetTop(L, 1); // (obj)
CallScript(Offsets::FUN_SCRIPT_REGION_GETPARENT, L); // (obj, parent|nil)
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL)
break;
Game::Lua::Remove(L, 1); // drop obj → (parent) becomes index 1
}
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, eff);
return 1;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
@@ -274,6 +423,9 @@ const Game::HookAutoRegister _setPointHook{
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
{"IsMouseOver", &Script_IsMouseOver},
{"GetRect", &Script_GetRect},
{"IsDragging", &Script_IsDragging},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
@@ -282,6 +434,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
+82
View File
@@ -0,0 +1,82 @@
// 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/>.
// `C_Glue` session helpers.
//
// `C_Glue.IsFirstLoadThisSession()` — glue-only. Returns true only while the
// FIRST glue screen since the process launched is showing, and false on
// every return to glue after a world login (a genuine world→glue logout;
// login↔character-select stays within the first glue session). Retail uses
// it so the login UI runs one-time startup (intro cinematic, news) only on
// the initial launch, not on each relog.
//
// Backed by a process-static boot counter, no engine state needed: the glue
// registration callback fires exactly once per glue boot
// (FUN_LOAD_GLUE_SCRIPT_FUNCTIONS — initial launch + each world→glue return),
// so the counter equals "how many glue screens this session" and the first
// one is boot #1. The glue Lua state is recreated per boot, so C_Glue is
// re-registered each time; the counter is process-static and survives that.
//
// `C_Glue.IsOnGlueScreen()` — true when a GlueXML screen is showing (no
// character logged in), false in the world. Registered on BOTH Lua states,
// and the answer is a compile-time constant per state: the glue and in-game
// Lua states are mutually exclusive (glue exists only when no character is in
// the world), so the glue registration returns true and the in-game one
// returns false — no runtime flag to consult. Available in both states so
// shared code that runs in either environment can branch on it.
#include "Game.h"
namespace Glue::Session {
namespace {
// Glue-screen boots since process start. 1 during the very first glue screen.
int g_glueBootCount = 0;
int __fastcall Script_IsFirstLoadThisSession(void *L) {
Game::Lua::PushBool(L, g_glueBootCount <= 1);
return 1;
}
int __fastcall Script_IsOnGlueScreen_True(void *L) {
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsOnGlueScreen_False(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
void RegisterGlue() {
++g_glueBootCount; // one glue boot per callback invocation
Game::Lua::RegisterTableFunction("C_Glue", "IsFirstLoadThisSession",
&Script_IsFirstLoadThisSession);
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_True);
}
void RegisterInGame() {
// In-world: only IsOnGlueScreen makes sense (and it's always false).
// IsFirstLoadThisSession is a glue concept, so it's not registered here.
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_False);
}
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
} // namespace
} // namespace Glue::Session
+13 -2
View File
@@ -21,7 +21,7 @@
namespace Item::Arg {
Resolved Resolve(void *L, int idx) {
Resolved out{0, nullptr};
Resolved out{0, 0, nullptr};
if (Game::Lua::IsNumber(L, idx)) {
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
return out;
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
return out;
}
if (const char *m = std::strstr(s, "item:")) {
out.itemID = std::atoi(m + 5);
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
out.suffix = std::atoi(p + 1);
break;
}
}
return out;
}
// Plain string — try numeric, otherwise expose as a name for
+1
View File
@@ -22,6 +22,7 @@ namespace Item::Arg {
// can ignore `name` and treat `itemID == 0` as "no usable input".
struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
};
+15 -4
View File
@@ -259,7 +259,8 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Item::Arg::Resolved arg = Item::Arg::Resolve(L, 1);
const int itemID = arg.itemID;
if (itemID <= 0)
return 0;
@@ -288,14 +289,24 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
// Apply the link's random suffix ("... of the Owl") to both the name and
// the reconstructed link when the input carried one (arg.suffix); with no
// suffix these reduce to the base name / plain `item:N:0:0:0` link.
char suffixedName[128];
const char *displayName =
Item::Link::NameFromIDSuffix(static_cast<uint32_t>(itemID), arg.suffix,
suffixedName, sizeof(suffixedName))
? suffixedName
: name;
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
const bool haveLink = Item::Link::BasicFromIDSuffix(static_cast<uint32_t>(itemID),
arg.suffix, link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
Game::Lua::PushString(L, PushedOrEmpty(displayName)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
+31 -7
View File
@@ -30,6 +30,10 @@ using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Inner name builder, no CGItem needed: (out, outSize, itemID, suffixID).
using BuildNameFromID_t = void(__fastcall *)(char *out, unsigned outSize,
uint32_t itemID, int suffixID);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -63,25 +67,45 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildNameFromID_t>(
Offsets::FUN_ITEM_BUILD_NAME_FROM_ID);
fn(out, static_cast<unsigned>(outSize), itemID, suffixID);
return out[0] != '\0';
}
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr)
return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return false;
char name[128];
if (!NameFromIDSuffix(itemID, suffixID, name, sizeof(name))) {
// Suffix builder yielded nothing (e.g. suffix row missing) — fall back
// to the base record name so we at least produce a valid link.
const char *base = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (base == nullptr || *base == '\0')
return false;
std::snprintf(name, sizeof(name), "%s", base);
}
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
"%s|Hitem:%u:0:%d:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffixID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
return BasicFromIDSuffix(itemID, 0, out, outSize);
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+14
View File
@@ -51,4 +51,18 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
// Writes the suffix-decorated display name for `(itemID, suffixID)` into
// `out` WITHOUT a live item instance — the by-string analog of
// NameFromCGItem. `suffixID` is the 3rd `item:` field (random property /
// suffix); 0 = base name. Applies the "... of the Owl" suffix via the
// engine's inner builder (FUN_ITEM_BUILD_NAME_FROM_ID) when the item is
// cached and the suffix is a valid ItemRandomProperties row. Returns false
// (out empty) if the item isn't cached or has no name — warm and retry.
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
// Like BasicFromItemID but preserves the random `suffixID` in both the
// `item:` payload (`item:N:0:suffix:0`) and the bracketed, suffix-decorated
// name. `suffixID` 0 reduces exactly to BasicFromItemID.
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
} // namespace Item::Link
+20 -1
View File
@@ -94,8 +94,27 @@ static int __fastcall Script_C_Item_GetItemName(void *L) {
// for `itemID`, firing a background cache fill on miss. Accepts the
// same input shapes as `GetItemInfoInstant` (number, `"item:NNN..."`
// string, or any string containing a parseable `item:NN` chunk).
//
// When the input is an item link carrying a random suffix
// (`item:12022:0:769:0`), the returned name is suffix-decorated
// ("Iridium Chain of the Owl") — the 3rd `item:` field is parsed and
// applied via the engine's own name builder, so it matches the global
// `GetItemInfo`'s name. A bare itemID (number or `item:N`) has no suffix
// and yields the base name.
static int __fastcall Script_C_Item_GetItemNameByID(void *L) {
return PushNameForItemID(L, Item::Arg::ResolveItemID(L, 1));
const Item::Arg::Resolved r = Item::Arg::Resolve(L, 1);
if (r.itemID <= 0)
return 0;
char buf[128];
if (Item::Link::NameFromIDSuffix(static_cast<uint32_t>(r.itemID), r.suffix,
buf, sizeof(buf))) {
Game::Lua::PushString(L, buf);
return 1;
}
// Not cached yet — fire a background fill and return nil (same miss
// behavior as the base-name path).
Item::Data::WarmCache(static_cast<uint32_t>(r.itemID));
return 0;
}
static void RegisterLuaFunctions() {
+106 -25
View File
@@ -30,11 +30,23 @@
//
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
// per frame in which a bag actually changed. On that signal we scan bags
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
// (no payload — matching retail) fires whenever the new-item set changes. So
// nothing runs on quiet frames.
// per frame in which a bag actually changed. On that signal we scan bags 0..4,
// flag any resident GUID that isn't in the owned "seen-set", reconcile the
// seen-set against everything owned now, and prune flags for items that have
// genuinely left. `BAG_NEW_ITEMS_UPDATED` (no payload — matching retail) fires
// whenever the new-item set changes. Nothing runs on quiet frames.
//
// The seen-set is deliberately NOT rebuilt from each scan: a bag update in
// progress transiently drops items from the read — sometimes the entire
// backpack at once, for a frame or two — and a naive "seen = this scan"
// approach would then re-flag the momentarily-missing item as new when it
// reappears (the exact bug this system had). Instead each entry carries a
// consecutive-absent-scan counter (`SeenReconcile`): present items reset it to
// 0, absent ones age by one, and only items absent `kMaxMissStreak` scans
// running are evicted. Counting scans (not wall-clock) means a pause with no
// bag activity never ages an owned item; any reappearance resets the streak.
// This keeps the set BOUNDED to ~currently-owned items — genuinely-removed
// items age out — while absorbing the transient reads.
//
// The one exception is establishing the login baseline: a lightweight
// WorldTick handler waits ~1.5s after the player is first resolvable (long
@@ -47,7 +59,7 @@
// — even though only *bag* items are ever flagged new. That's what stops a
// move INTO a bag from any of those (unequip, or a pull from the bank) from
// looking like a fresh loot: the item's GUID was already in the owned set, so
// the bag diff treats it as a move. The bank is reachable without the bank
// the seen-set treats it as already-owned, not new. The bank is reachable without the bank
// window open because its GUIDs are read straight from the invMgr array (the
// same gate-bypass `C_Item.GetItemCount` uses; the array is server-populated
// at login).
@@ -88,7 +100,25 @@ constexpr int kMaxItems = 512;
// engine ms counter) so it's independent of frame rate.
constexpr uint32_t kSettleMs = 1500;
uint64_t g_seen[kMaxItems]; // all owned GUIDs (bags + equipment + bank) at the last scan
// Consecutive bag-change scans an item may be absent from the read before it's
// dropped from the seen-set. A bag update in progress transiently drops items
// from the scan — sometimes the whole backpack at once — for a frame or two;
// counting SCANS (not wall-clock) means a pause with no bag activity never ages
// an owned item, and any reappearance resets the streak to 0, so a transient
// miss never reaches the threshold. Genuinely-removed items hit it within a few
// subsequent bag changes and age out — that's what keeps the set BOUNDED to
// ~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 {
uint64_t guid;
int miss;
};
SeenEntry g_seen[kMaxItems];
int g_seenCount = 0;
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
int g_newCount = 0;
@@ -115,6 +145,44 @@ bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
return true;
}
// --- seen-set (bounded, absent-scan-count evicted) -----------------------
bool SeenContains(uint64_t guid) {
for (int i = 0; i < g_seenCount; ++i)
if (g_seen[i].guid == guid)
return true;
return false;
}
// One scan's reconcile: age every entry by one missed scan, then reset the
// streak (and insert) for everything owned right now, then evict entries that
// have been absent `kMaxMissStreak` scans running. `owned` is the union of the
// current bag scan and non-bag owned items (equipment + bank).
void SeenReconcile(const uint64_t *owned, int ownedCount) {
for (int i = 0; i < g_seenCount; ++i)
++g_seen[i].miss;
for (int j = 0; j < ownedCount; ++j) {
const uint64_t guid = owned[j];
if (guid == 0)
continue;
bool found = false;
for (int i = 0; i < g_seenCount; ++i)
if (g_seen[i].guid == guid) {
g_seen[i].miss = 0;
found = true;
break;
}
if (!found && g_seenCount < kMaxItems)
g_seen[g_seenCount++] = {guid, 0};
}
for (int i = 0; i < g_seenCount;) {
if (g_seen[i].miss >= kMaxMissStreak)
g_seen[i] = g_seen[--g_seenCount];
else
++i;
}
}
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
for (int i = 0; i < *count; ++i) {
if (arr[i] == guid) {
@@ -247,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);
}
@@ -257,12 +327,6 @@ void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
}
void CopyToSeen(const uint64_t *current, int count) {
for (int i = 0; i < count; ++i)
g_seen[i] = current[i];
g_seenCount = count;
}
// Login-baseline handler. Runs every frame but does no work once the
// baseline is taken — it only watches for (re)login and, after a short
// settle, takes the single authoritative baseline scan. Steady state is just
@@ -290,11 +354,14 @@ void OnTick() {
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently owned — bags AND worn equipment — becomes the
// Everything currently owned — bags AND worn equipment + bank — becomes the
// baseline; nothing owned at login is "new". Including equipment means a
// later unequip (paperdoll → bag) is recognized as a move, not a loot.
g_seenCount = ScanBags(player, g_seen);
AppendNonBagOwned(g_seen, &g_seenCount);
uint64_t owned[kMaxItems];
int ownedCount = ScanBags(player, owned);
AppendNonBagOwned(owned, &ownedCount);
g_seenCount = 0;
SeenReconcile(owned, ownedCount);
g_newCount = 0;
g_seeded = true;
}
@@ -316,21 +383,35 @@ void OnBagChanged() {
const int bagCount = ScanBags(player, currentBags);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
// Flag bag items we've never owned. An item merely dropped from a recent
// scan (bag update mid-swap) is still in the seen-set (its miss streak is
// below the threshold), so it is NOT re-flagged when it reappears — that
// was the false-"new" bug. Checked before the reconcile so a genuinely new
// item isn't inserted first.
for (int i = 0; i < bagCount; ++i) {
if (!SeenContains(currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
// Reconcile the seen-set against everything owned now (bags + equipment +
// bank): resets present items, ages absent ones, evicts long-gone ones.
uint64_t owned[kMaxItems];
int ownedCount = 0;
for (int i = 0; i < bagCount; ++i)
AddUnique(owned, &ownedCount, currentBags[i]);
AppendNonBagOwned(owned, &ownedCount);
SeenReconcile(owned, ownedCount);
// Drop flags only for items that have genuinely left (fallen out of the
// seen-set entirely), not ones merely missing from this one scan.
for (int i = 0; i < g_newCount;) {
if (!Contains(currentBags, bagCount, g_new[i])) {
if (!SeenContains(g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
for (int i = 0; i < bagCount; ++i) {
if (!Contains(g_seen, g_seenCount, currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
CopyToSeen(currentBags, bagCount);
AppendNonBagOwned(g_seen, &g_seenCount);
if (changed)
FireChanged();
+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, "");
+19 -8
View File
@@ -140,20 +140,31 @@ bool Resolve() {
namespace {
// 4th arg is the GUID high dword. Ghidra types it `float` from usage, but
// it's a plain dword on the stack (the engine assigns it the current
// selection's high dword when 0); `unsigned` puts the correct 4 bytes there.
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
unsigned targetGuidLo, unsigned targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
void DispatchSlot(int slot0Based, int bookType, uint64_t targetGuid) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
// GUID 0 = no implicit target (engine uses current selection / placement).
// A real GUID casts a unit-target spell straight at that unit.
dispatch(static_cast<unsigned>(slot0Based), bookType,
static_cast<unsigned>(targetGuid),
static_cast<unsigned>(targetGuid >> 32));
}
} // namespace
bool DispatchSpellCast(int spellID) {
bool IsPlacementActive() {
return *reinterpret_cast<const uint32_t *>(
Offsets::VAR_SPELL_PLACEMENT_STATE) != 0;
}
bool DispatchSpellCast(int spellID, uint64_t targetGuid) {
if (spellID <= 0)
return false;
@@ -167,11 +178,11 @@ bool DispatchSpellCast(int spellID) {
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
DispatchSlot(slot1 - 1, bookType, targetGuid);
return true;
}
bool DispatchSpellCastByName(const char *name) {
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid) {
if (name == nullptr || *name == '\0')
return false;
@@ -185,7 +196,7 @@ bool DispatchSpellCastByName(const char *name) {
if (slot < 0)
return false; // not in the player's spellbook
DispatchSlot(slot, bookType);
DispatchSlot(slot, bookType, targetGuid);
return true;
}
+17 -2
View File
@@ -13,8 +13,16 @@
#pragma once
#include <cstdint>
namespace Spell::AtCursor {
// True while the engine has an in-flight targeting placement of any kind
// (ground reticle, unit/item/corpse select). `VAR_SPELL_PLACEMENT_STATE != 0`.
// Used by `CastAtUnit` to tell a unit-target spell that fired immediately
// (no placement) from a ground spell that's waiting for a location.
bool IsPlacementActive();
// Resolves the engine's in-flight ground-target placement at the
// player's current cursor world position. Intended to be called
// immediately after initiating a ground-target cast (spell or item)
@@ -51,7 +59,14 @@ bool CommitAtCoords(const float coords[3]);
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
//
// `targetGuid` is the implicit cast target: `0` = the engine's default
// (current selection, or ground placement for AoE); a unit's GUID makes
// a unit-target/normal spell fire directly on that unit (the engine
// substitutes it exactly where it would the current target). Ground
// spells ignore it and still enter placement. `CastAtUnit` passes the
// resolved unit's GUID; `CastAtCursor` leaves it 0.
bool DispatchSpellCast(int spellID, uint64_t targetGuid = 0);
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid = 0);
} // namespace Spell::AtCursor
+47 -26
View File
@@ -11,33 +11,46 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.CastAtUnit(spellID, unit)` — cast a ground-target spell at
// the given unit's feet, bypassing the manual click on the AoE
// reticle. `CastAtUnit(spellID, "player")` drops it at the player's
// position, `"target"` at the target's, etc.
// `C_Spell.CastAtUnit(spellIDOrName, unit)` — cast a spell at the given
// unit, whatever its target type. One primitive covering both:
//
// Same two-step shape as `C_Spell.CastAtCursor`, but the placement is
// committed at the unit's world position instead of the cursor
// raycast:
// - **Ground-target spells** (Blizzard, Flamestrike, Rain of Fire…) are
// placed at the unit's feet — the reticle is committed at the unit's
// world position, no manual click.
// - **Unit-target / normal spells** (Frostbolt, Healing Touch, a buff…)
// fire directly on the unit — its GUID is fed to the engine's cast
// dispatcher as the implicit target, so no current-target juggling and
// no "select target" reticle.
//
// 1. `Spell::AtCursor::DispatchSpellCast(spellID)` initiates the
// cast; the engine enters placement mode for a ground-target
// spell.
// 2. Read the unit's world position (vtable `GetPosition`) and
// `Spell::AtCursor::CommitAtCoords()` commits the placement
// there.
// Mechanism: resolve the token once to the unit object, read its position
// and GUID, then dispatch the cast WITH that GUID
// (`Spell::AtCursor::DispatchSpellCast(spell, guid)`). The engine's
// dispatcher substitutes the GUID exactly where it would the current
// selection, so a unit-target spell fires immediately at that unit. A
// ground spell ignores the GUID and enters placement mode instead — so we
// branch on `IsPlacementActive()`:
//
// Returns `true` when the placement was committed at the unit; `false`
// when the spell isn't ground-target (placement cancelled), the spell
// isn't in the spellbook, or the unit has no resolvable position. The
// cast itself still proceeds for non-ground spells, just with no
// implicit target — matching `CastAtCursor`'s semantics.
// - No placement after dispatch → the cast already went out on the unit
// (unit-target / normal). Success.
// - Ground placement active → commit at the unit's feet. Success.
// - Other placement still active → the engine didn't accept the unit as a
// target (e.g. wrong faction / out of range); `CommitAtCoords` cancels
// it. Failure.
//
// Returns `true` when the spell was cast at the unit; `false` when the
// spell isn't in the spellbook, the unit has no resolvable position, or the
// unit wasn't a valid target. A genuinely unrecognized token string raises
// the engine's standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.
#include "Game.h"
#include "spell/AtCursor.h"
#include "spell/MacroPrimarySpell.h"
#include "unit/Identity.h"
#include "unit/Position.h"
#include <cstdint>
namespace Spell::AtUnit {
namespace {
@@ -53,27 +66,35 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
}
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
// bad/absent unit doesn't leave the engine sitting in placement
// mode. (A genuinely unrecognized token string raises the engine's
// standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.)
// Resolve the unit once → object, then read position + GUID from it.
// Fail fast so a bad/absent unit doesn't leave the engine mid-cast.
void *unitObj = Unit::Position::ResolveToken(token);
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
if (unitObj == nullptr || !Unit::Position::Read(unitObj, pos)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t targetGuid = Unit::Identity::GuidForObject(unitObj);
const bool dispatched =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
static_cast<int>(Game::Lua::ToNumber(L, 1)), targetGuid)
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1),
targetGuid);
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
// Unit-target / normal spells fired straight at the GUID — no placement.
// Ground spells are now sitting in placement mode; commit at the unit's
// feet (CommitAtCoords cancels + fails for a non-ground placement, i.e.
// an unaccepted unit target).
if (!Spell::AtCursor::IsPlacementActive()) {
Game::Lua::PushBool(L, true);
return 1;
}
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
Game::Lua::PushBool(L, placed);
return 1;
+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
+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
+87
View File
@@ -0,0 +1,87 @@
// 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/>.
// UnitPosition backport.
//
// `UnitPosition("unit") → posY, posX, posZ, instanceID` — the modern
// (WoD+) world-position accessor, ported to 1.12. The world position of
// any visible unit already lives on its CGObject; we read it through the
// shared `Unit::Position` helper (the object's GetPosition virtual, vtable
// slot 5 — the same path UnitDistanceSquared / UnitInRange use).
//
// Two semantic notes:
//
// - **Order matches retail.** The engine's C3Vector is {x, y, z} with the
// standard WoW convention (x = north, y = west, z = up). Retail's
// UnitPosition returns (posY, posX, posZ) — west, north, up — so we push
// the engine's y, then x, then z. An addon ported from retail that reads
// `posY, posX = UnitPosition(u)` gets coordinates labelled exactly as it
// expects.
// - **No group restriction.** Retail returns nil for units outside your
// party/raid (a taint/privacy guard that doesn't exist in 1.12). We can
// read any *visible* unit, so this is strictly more permissive — closer
// to SuperWoW's unit-position access than to retail's restricted form.
// Units the client can't see (out of range, different zone) have no
// known position → nil.
//
// `instanceID` is the currently-loaded Map.dbc id (VAR_CURRENT_MAP_ID) —
// every visible unit shares the player's instance, so the player's map id
// is the unit's instance id.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Unit::PositionApi {
namespace {
// `UnitPosition("unit") → posY, posX, posZ, instanceID`. nil when the unit
// has no known position (recognized-but-absent token, or a visible-object
// miss). Genuinely unrecognized token strings raise a Lua error, same as
// UnitHealth("garbage") — the engine's resolver owns that contract.
int __fastcall Script_UnitPosition(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPosition(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(pos[1])); // posY (world Y, west)
Game::Lua::PushNumber(L, static_cast<double>(pos[0])); // posX (world X, north)
Game::Lua::PushNumber(L, static_cast<double>(pos[2])); // posZ (up)
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
Game::Lua::PushNumber(L, static_cast<double>(mapId));
return 4;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitPosition", &Script_UnitPosition);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "UnitPosition",
&Script_UnitPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::PositionApi
+43
View File
@@ -31,6 +31,22 @@ namespace {
using CancelAuraSend_t = void(__fastcall *)(int spellID);
// `FUN_UNIT_IS_OUTDOORS(unit) -> uint`, nonzero = outdoors (see Offsets.h).
// The thunk tail-jumps into the WMO query, so eax carries the result even
// though a decompiler renders the thunk itself as `void`.
using IsOutdoorsQuery_t = uint32_t(__fastcall *)(const void *unit);
// Tri-state indoor/outdoor read for the local player: 1 = outdoors,
// 0 = indoors, -1 = unknown (no resolvable player object, e.g. pre-world).
int PlayerOutdoorState() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return -1;
auto fn = reinterpret_cast<IsOutdoorsQuery_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_IS_OUTDOORS));
return fn(player) != 0 ? 1 : 0;
}
uint32_t PlayerMovementFlags() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
@@ -200,10 +216,37 @@ int __fastcall Script_IsAssistingRitual(void *L) {
return 1;
}
// `IsIndoors()` — 1 iff the local player is under a WMO roof (inside a
// building / cave / instance interior), else nil. Recomputed live from the
// engine's WMO geometry query, so it flips as you cross an interior
// threshold — not tied to the zone/area. nil before the player object
// exists (pre-world). Vanilla 1.12 ships no binding for this.
//
int __fastcall Script_IsIndoors(void *L) {
if (PlayerOutdoorState() == 0)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// `IsOutdoors()` — 1 iff the local player is outdoors (open sky, or not
// inside a WMO interior), else nil. Exact complement of `IsIndoors` for a
// resolvable player; both return nil pre-world. See `Script_IsIndoors`.
int __fastcall Script_IsOutdoors(void *L) {
if (PlayerOutdoorState() == 1)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMounted", &Script_IsMounted);
Game::Lua::RegisterGlobalFunction("IsIndoors", &Script_IsIndoors);
Game::Lua::RegisterGlobalFunction("IsOutdoors", &Script_IsOutdoors);
Game::Lua::RegisterGlobalFunction("Dismount", &Script_Dismount);
Game::Lua::RegisterGlobalFunction("IsStealthed", &Script_IsStealthed);
Game::Lua::RegisterGlobalFunction("IsFalling", &Script_IsFalling);