Compare commits

...
34 Commits
Author SHA1 Message Date
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
Brues a5b6cf367d aura: cache fallback for out-of-range group auras (server delta gap)
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
2026-07-26 18:19:47 -05:00
Brues b496568670 Add C_Spell.GetSpellEffectMechanics(spellID)
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.

Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
2026-07-26 15:42:25 -05:00
Brues a37c590a72 aura: clamp elapsed player-buff expiration to 0 (permanent auras)
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
2026-07-26 12:16:48 -05:00
Brues 60479677c1 aura: best-effort duration guess for out-of-range group/raid auras
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.

Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
2026-07-26 03:05:54 -05:00
Brues b5038dc3fd util: add VectorLib (Vector2D / Vector3D / Vector4D)
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
2026-07-26 03:00:03 -05:00
Brues 0bbf802316 Use BASE_MOVEMENT_SPEED for speed calculation
Replace the magic divisor 7 with the BASE_MOVEMENT_SPEED constant
2026-07-26 02:53:34 -05:00
Brues 0fcaa2e064 util: add RectangleMixin / CreateRectangle
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
2026-07-26 02:49:02 -05:00
Brues 6724ddd2bd mathutil: CreateCounter takes an optional step
Retail added a second `step` parameter (default 1); mirror it. Backward
compatible -- existing no-step callers are unchanged.
2026-07-26 02:46:58 -05:00
Brues 50d001a71f mathutil: add CreateAccumulator / AccumulatorMixin
Backport of retail's AccumulatorMixin (Init/Add/Subtract/Count/Reset)
and the CreateAccumulator factory, built on CreateAndInitFromMixin.
2026-07-26 02:42:40 -05:00
Brues 3aad341a91 table: add table.count(tbl)
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
2026-07-26 02:24:19 -05:00
Brues 3141acf547 baselib: provide Mixin and CreateFromMixins as C natives
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.

Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
2026-07-26 02:12:22 -05:00
Brues 05e7bda5da color: add C_ColorUtil and wire the addon Color files to it
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.

Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
2026-07-26 01:57:56 -05:00
Brues c4b48f7f42 spell: add CanDualWield()
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
2026-07-26 01:30:11 -05:00
Brues 4d97b1c449 clipboard: add CopyToClipboard(text [, removeMarkup])
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.

Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
2026-07-26 01:18:42 -05:00
Brues e447c70ca3 aura: recover player-talented DoT duration in the application hooks
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).

Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.

Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
2026-07-26 00:00:42 -05:00
Brues 72fa36956e tooltip: add OnTooltipSetSpell/Unit/GameObject scripts
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:

- Spell  -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
  7 stack args, RET 0x1c)
- Unit   -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
  caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)

Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.

Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.

Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
2026-07-25 22:57:32 -05:00
Brues 40813a715c talent: GetTalentSpellID takes an optional classID too
Mirror the classID extension just added to GetTalentIDByIndex. classID
is the 4th arg (GetTalentSpellID(tab, idx[, rank[, classID]])); pass rank
as nil to keep its default. A numeric classID resolves the talent
against the Talent.dbc / TalentTab.dbc flat arrays via the shared
ResolveTalentRecordForClass, then reads SpellRank[rank-1] off the record.

One semantic difference from the same-class path: there's no player
currentRank for a class you aren't, so a cross-class query with no
explicit rank defaults to rank 1 (the canonical spellID) rather than
delegating to the engine's GetTalentInfo.

Verified in-game: own-class parity at rank 1 (14524 == 14524), Mage (8)
tab1 talent1 -> Arcane Subtlety (11210 r1, 12592 r2, r9 nil),
default-rank cross-class == rank 1, and bad tab/index/class all nil.
2026-07-25 15:05:34 -05:00
Brues a14ccfacbb talent: GetTalentIDByIndex takes an optional classID
Previously it only read the local player's talent tree (the engine's
runtime per-tab arrays at VAR_TALENT_TAB_INFO_ARRAY, built only for the
player's class). Addons that want another class's talent IDs -- build
tooling, spec previews -- had no way to get them.

Add an optional 3rd arg: nil/omitted keeps the runtime path (player's
class, unchanged); a numeric classID resolves against the Talent.dbc /
TalentTab.dbc flat arrays instead, so any class works.

Ordering matches the player-class path exactly because it replicates the
engine's tree builder FUN_004f2c00: tabs taken from TalentTab.dbc in row
order filtered by classMask (+0x30; mask 0 = all, race not a factor --
FUN_004f2e50 is a plain bitmask test), then talents from Talent.dbc in
row order within each TabID. The runtime arrays are built from that same
walk, so GetTalentIDByIndex(t,i) == GetTalentIDByIndex(t,i,<player class>).

New Offsets: the flat (row-order) DBC array globals the builder uses
(VAR_TALENTTAB_DBC_FLAT_* / VAR_TALENT_DBC_FLAT_*), distinct from the
sparse by-ID index SetTalentByID uses.

Verified in-game: own-class parity (166 == 166), Mage (8) tabs 1/2/3
first talents = 37/1/18, and out-of-range tab/index/class all return nil
with no fault.
2026-07-25 14:58:49 -05:00
Brues 4afe0b9eae aura: read out-of-range groupmate auras from the roster
C_UnitAuras.* returned nothing for a party/raid member with no live
CGUnit (different map / far out of range): every path bailed on the
null unit, and the only fallback was the Aura::Source SMSG_SPELL_GO
cache, which needs the object to exist.

The server keeps transmitting each group member's current aura spell
IDs via SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS / _NEGATIVE)
and the client stashes them in the group-member roster structs -- the
same source the built-in UnitBuff/UnitDebuff read out of range. Surface
them.

- Group::MemberStats::AuraArray(guid) mirrors the engine's own
  out-of-range fallback in Script_UnitBuff/Script_UnitDebuff exactly
  (party stats block +0x1a gated on the online flag, else raid slot
  +0x64; both 48x u16).
- Factor aura-table enrichment (caster/expiration/duration) into a
  single descriptor-independent PushEnriched core; Push and
  PushFromCache now delegate to it.
- New group-array enumerate branch (PushNthGroupAura /
  PushGroupAuraBySpellID / ...BySpellName / AppendGroupAuras), applying
  the engine's per-slot visibility predicate and merging any
  caster/timing the SpellGo cache observed.
- Api.cpp routes unit==null to the group array for every C_UnitAuras
  entry point. Safe: the token resolver never raises (returns 0), and
  the GUID lookup only runs after ResolveUnit already accepted the
  token.

Spell IDs only (no stacks; IDs are u16-truncated) -- same limitations
as UnitBuff. Verified in-game: GetUnitAuras("raid1") cross-map returns
Power Word: Fortitude (10938), matching UnitBuff.
2026-07-25 14:33:58 -05:00
Brues c01dceabb8 unit: class/race fall back to the player-info cache out of range
UnitClassBase / UnitRaceBase resolved via FUN_RESOLVE_UNIT_TOKEN and returned
(nil,nil) for a groupmate on another map -- but the engine's own UnitClass /
UnitRace fall back to the cached player-info record there (verified in-game:
UnitClass("raid1")="Priest", UnitRace("raid1")="Human" while our descriptor
path yielded nil). Give ours the same fallback: on a descriptor miss, read
class (+0x140) / race (+0x138) from the NameCache record by GUID. Verified
fixed: UnitClassBase("raid1")=("PRIEST",5), UnitRaceBase("raid1")=("Human",1).

That engine-NameCache peek existed in four places (the two above plus
loot/History and player/Info). Collapsed them into one primitive,
Unit::Identity::PlayerInfoRecord(guid) -> record* (null callback = peek only),
and pointed all four at it. FUN_PLAYER_INFO_LOOKUP is now called in exactly
one spot.
2026-07-25 13:19:04 -05:00
Brues 365df6b882 group: add Group::MemberStats, generalize the out-of-range roster reader
Extract the roster fallback (added for UnitPower) into a shared
Group::MemberStats reader and give it the full picture. Lookup(guid) tries
the party stats block (FUN_GROUP_MEMBER_STATS_LOOKUP) then the raid slot
(FUN_GROUP_MEMBER_SLOT_LOOKUP) -- the object-independent group roster fed by
SMSG_PARTY_MEMBER_STATS -- and returns health, maxHealth, power, maxPower,
powerType, level, areaId, online (both paths) plus dead, subgroup, rank (raid
path). Raid-struct layout mapped from Script_GetRaidRosterInfo (0x004bb560):
subgroup@+8, rank@+0xc, flags@+0x18 (bit0 online, bit2 dead), level@+0x22,
areaId@+0x24, plus the health/power fields already used.

UnitPower/Max/Missing now go through it (no behavior change -- verified target
still reads UnitPower == UnitMana == 2896 on a different map), and
UnitHealthMissing gains the same fallback so it no longer returns 0 for an
out-of-range groupmate. UnitPowerType already had the engine's own roster
fallback (0x00517940) and needs nothing.

The extra fields (level/area/online/dead/subgroup/rank) are populated but not
yet consumed -- groundwork for fixing UnitLevel / UnitIsConnected / UnitIsDead
and a full GetRaidRosterInfo for out-of-range members.
2026-07-25 12:59:55 -05:00
Brues 72de6c0960 unit: fall back to the group roster for out-of-range power reads
UnitPower / UnitPowerMax / UnitPowerMissing read the CGUnit descriptor
directly, so they returned 0 for a party/raid member on a different map
(no live object). The engine's UnitMana/UnitManaMax don't -- they fall back
to the cached member stats from SMSG_PARTY_MEMBER_STATS.

Mirror that fallback: when ResolveArgs finds no live object, resolve
token -> GUID and read the member's primary power from the party stats block
(FUN_GROUP_MEMBER_STATS_LOOKUP: type@+9, cur@+0xe, max@+0x10) then the raid
slot (FUN_GROUP_MEMBER_SLOT_LOOKUP: type@+0x58, cur@+0x60, max@+0x62), all
u16 -- exactly Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
Verified in-game: a different-map target now reports UnitPower == UnitMana
(2896) instead of 0.

Limitation (inherent): the roster caches only the member's primary power, so
an explicit non-primary powerType returns 0 out of range. Reached only after
ResolveArgs fails on an already-valid token, so TokenToGUID can't raise.
2026-07-25 12:42:06 -05:00
Brues eeba4ada9d focus: keep focus on party/raid members that leave range
3.3.5 does not drop focus the instant a unit leaves the object table -- its
object-teardown purge (FUN_00524350) is gated by FUN_00512a30, which is
"party-member (FUN_0052d310) OR raid-member (FUN_00573200)". So a groupmate
whose CGUnit is destroyed by going out of range/LoS keeps focus and reattaches
when they return; only non-group units clear on despawn. (Verified in 3.3.5
via Ghidra: focus GUID at DAT_00bd07d0/d4, sole writer FUN_0051ff20, purge
gate decoded to the party/raid roster checks.)

OnWorldTick now mirrors that gate: on an ObjectByGUID miss it keeps focus when
IsGroupMemberGuid(g_focusGUID) -- reading the object-independent roster GUIDs
(VAR_PARTY_GUIDS 4-slot array + VAR_RAID_MEMBER_PTRS 40-slot member-ptr array,
GUID at *member+0) -- otherwise clears as before. A new g_focusObserved flag
tracks whether the "focus" unit-event observers are attached to a live object;
they die with the object on despawn, so OnWorldTick re-registers them when a
groupmate returns to range, and Set() registers only against a resolvable
object (deferring to OnWorldTick when focusing an out-of-range member).

Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
2026-07-25 12:19:51 -05:00
51 changed files with 4037 additions and 621 deletions
+5
View File
@@ -38,3 +38,8 @@ Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
+17 -40
View File
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
return self.r, self.g, self.b
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
return h, s, l, a or 1
end
function ColorMixin:GetRGBAsBytes()
return self.r * 255, self.g * 255, self.b * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
end
function ColorMixin:GetRGBA()
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
end
function ColorMixin:GetRGBAAsBytes()
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
end
function ColorMixin:SetRGBA(r, g, b, a)
self.r = r
self.g = g
self.b = b
self.a = a
self.a = a or 1
end
function ColorMixin:SetRGB(r, g, b)
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
end
function ColorMixin:GenerateHexColor()
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
return C_ColorUtil.GenerateTextColorCode(self)
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
end
function ColorMixin:GenerateHexColorMarkup()
return "|c"..self:GenerateHexColor()
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local max, min = math.max(r, g, b), math.min(r, g, b)
local h, s, l
l = (max + min) / 2
if max == min then
h, s = 0, 0 -- achromatic
else
local d = max - min
if l > 0.5 then
s = d / (2 - max - min)
else
s = d / (max + min)
end
if max == r then
h = (g - b) / d
if g < b then
h = h + 6
end
elseif max == g then
h = (b - r) / d + 2
elseif max == b then
h = (r - g) / d + 4
end
h = h / 6
end
return h, s, l, a or 1
end
function ColorMixin:WrapTextInColorCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, self)
end
function WrapTextInColorCode(text, colorHexString)
return string.format("|c%s%s|r", colorHexString, text)
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
end
function WrapTextInColor(text, color)
return WrapTextInColorCode(text, color:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, color)
end
function ColorMixin:WrapTextInColorTableCode(text)
+26 -13
View File
@@ -1,3 +1,8 @@
COLOR_FORMAT_RGBA = "RRGGBBAA"
COLOR_FORMAT_RGB = "RRGGBB"
COLOR_FORMAT_ARGB = "AARRGGBB"
FONT_COLOR_CODE_CLOSE = "|r"
do
local ColorMixin = ColorMixin
for _, classColor in pairs(RAID_CLASS_COLORS) do
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
end
function CreateColorFromHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
end
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
return nil;
end
function CreateColorFromRGBAHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
end
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
return nil;
end
function CreateColorFromRGBHexString(hexColor)
if string.len(hexColor) == 6 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
return CreateColor(r, g, b, 1);
else
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
end
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBestRGBHexString(hexColor)
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
return CreateColorFromRGBAHexString(hexColor);
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
return CreateColorFromRGBHexString(hexColor);
end
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBytes(r, g, b, a)
@@ -68,9 +83,7 @@ end
function GetClassColoredTextForUnit(unit, text)
local _, classFilename = UnitClass(unit);
local color = GetClassColorObj(classFilename);
if (color) then
return color:WrapTextInColorCode(text);
end
return color and color:WrapTextInColorCode(text) or text;
end
function GetFactionColor(factionGroupTag)
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
end
function RGBToColorCode(r, g, b)
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
end
function RGBTableToColorCode(rgbTable)
+38 -3
View File
@@ -11,12 +11,19 @@ local random = math.random
local atan2 = math.atan2
local sqrt = math.sqrt
math.huge = 1 / 0
MathUtil =
{
Epsilon = .000001,
};
function CreateCounter(initialCount)
MathUtil.ApproxZero = MathUtil.Epsilon;
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
function CreateCounter(initialCount, step)
local count = initialCount or 0
step = step or 1
return function()
count = count + 1
count = count + step
return count
end
end
@@ -130,3 +137,31 @@ end
function CalculateAngleBetween(x1, y1, x2, y2)
return atan2(y2 - y1, x2 - x1)
end
AccumulatorMixin = {};
function AccumulatorMixin:Init(initialCount)
self.count = initialCount or 0;
end
function AccumulatorMixin:Add(count)
self.count = self.count + count;
return self.count;
end
function AccumulatorMixin:Subtract(count)
self.count = self.count - count;
return self.count;
end
function AccumulatorMixin:Count()
return self.count;
end
function AccumulatorMixin:Reset(resetCount)
self.count = resetCount or 0;
end
function CreateAccumulator(initialCount)
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
end
+7 -20
View File
@@ -1,24 +1,11 @@
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
-- even with this addon disabled, mirroring retail where they live in
-- TableUtil as C functions. Only the composite helper (which calls a Lua
-- `:Init` method) stays here, like retail's Mixin.lua.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no select(); inside a vararg function the args are
-- gathered into an implicit `arg` table (`arg.n` for the count).
function Mixin(object, ...)
for i = 1, arg.n do
local mixin = arg[i]
if mixin then
for k, v in pairs(mixin) do
object[k] = v
end
end
end
return object
end
function CreateFromMixins(...)
return Mixin({}, unpack(arg))
end
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
-- so we forward them with unpack(arg) rather than 5.1's `...`.
function CreateAndInitFromMixin(mixin, ...)
local object = CreateFromMixins(mixin)
+1 -1
View File
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
end
function PlayerUtil.GetCurrentSpeed()
return GetUnitSpeed("player") / 7 * 100
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
end
function GetPlayerGuid()
+141
View File
@@ -0,0 +1,141 @@
RectangleMixin = {};
function CreateRectangle(left, right, top, bottom)
local rectangle = CreateFromMixins(RectangleMixin);
rectangle:OnLoad(left, right, top, bottom);
return rectangle;
end
function RectangleMixin:OnLoad(left, right, top, bottom)
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
end
function RectangleMixin:SetSides(left, right, top, bottom)
self.left = left;
self.right = right;
self.top = top;
self.bottom = bottom;
end
function RectangleMixin:Reset()
self.left = 0.0;
self.right = 0.0;
self.top = 0.0;
self.bottom = 0.0;
end
function RectangleMixin:Stretch(x, y)
self:Adjust(-x, x, -y, y);
end
function RectangleMixin:Move(x, y)
self:Adjust(x, x, y, y);
end
function RectangleMixin:Adjust(left, right, top, bottom)
self.left = self.left + left;
self.right = self.right + right;
self.top = self.top + top;
self.bottom = self.bottom + bottom;
end
function RectangleMixin:IsEmpty()
return self.left == self.right or self.top == self.bottom;
end
function RectangleMixin:IsInsideOut()
return self.left > self.right or self.top > self.bottom;
end
function RectangleMixin:EnclosesPoint(x, y)
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
end
function RectangleMixin:EnclosesRect(otherRect)
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
end
function RectangleMixin:IntersectsRect(otherRect)
return not (
self.left > otherRect.right or
self.right < otherRect.left or
self.top > otherRect.bottom or
self.bottom < otherRect.top
);
end
function RectangleMixin:GetTop()
return self.top;
end
function RectangleMixin:GetBottom()
return self.bottom;
end
function RectangleMixin:GetLeft()
return self.left;
end
function RectangleMixin:GetRight()
return self.right;
end
function RectangleMixin:GetWidth()
return self.right - self.left;
end
function RectangleMixin:GetHeight()
return self.bottom - self.top;
end
function RectangleMixin:GetCenter()
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:GetCenterH()
return Lerp(self.left, self.right, .5);
end
function RectangleMixin:GetCenterV()
return Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:SetTop(top)
self.top = top;
end
function RectangleMixin:SetBottom(bottom)
self.bottom = bottom;
end
function RectangleMixin:SetLeft(left)
self.left = left;
end
function RectangleMixin:SetRight(right)
self.right = right;
end
function RectangleMixin:SetWidth(width)
self.right = self.left + width;
end
function RectangleMixin:SetHeight(height)
self.bottom = self.top + height;
end
function RectangleMixin:SetSize(width, height)
self:SetWidth(width);
self:SetHeight(height);
end
function RectangleMixin:SetCenter(x, y)
local width = self:GetWidth();
local height = self:GetHeight();
self.left = x - width * .5;
self.right = x + width * .5;
self.top = y - height * .5;
self.bottom = y + height * .5;
end
+3
View File
@@ -0,0 +1,3 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
end
+142
View File
@@ -0,0 +1,142 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local sqrt = math.sqrt;
function Vector2D_ScaleBy(scalar, x, y)
return x * scalar, y * scalar;
end
function Vector2D_DivideBy(divisor, x, y)
return x / divisor, y / divisor;
end
function Vector2D_Add(leftX, leftY, rightX, rightY)
return leftX + rightX, leftY + rightY;
end
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
return leftX - rightX, leftY - rightY;
end
function Vector2D_Cross(leftX, leftY, rightX, rightY)
return leftX * rightY - leftY * rightX;
end
function Vector2D_Dot(leftX, leftY, rightX, rightY)
return leftX * rightX + leftY * rightY;
end
function Vector2D_GetLengthSquared(x, y)
return Vector2D_Dot(x, y, x, y);
end
function Vector2D_GetLength(x, y)
return sqrt(Vector2D_GetLengthSquared(x, y));
end
function Vector2D_Normalize(x, y)
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
end
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
end
function Vector2D_RotateDirection(rotationRadians, x, y)
local cosValue = cos(rotationRadians);
local sinValue = sin(rotationRadians);
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
end
Vector2DMixin = {};
function CreateVector2D(x, y)
local vector = CreateFromMixins(Vector2DMixin);
vector:OnLoad(x, y);
return vector;
end
function AreVector2DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector2DMixin:OnLoad(x, y)
self:SetXY(x, y);
end
function Vector2DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y;
end
function Vector2DMixin:GetXY()
return self.x, self.y;
end
function Vector2DMixin:SetXY(x, y)
self.x = x;
self.y = y;
end
function Vector2DMixin:ScaleBy(scalar)
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:DivideBy(scalar)
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:Add(other)
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Subtract(other)
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Cross(other)
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Dot(other)
return Vector2D_Dot(self.x, self.y, other:GetXY());
end
function Vector2DMixin:IsZero()
return self.x == 0 and self.y == 0;
end
function Vector2DMixin:GetLengthSquared()
return Vector2D_GetLengthSquared(self:GetXY());
end
function Vector2DMixin:GetLength()
return Vector2D_GetLength(self:GetXY());
end
function Vector2DMixin:Normalize()
self:SetXY(Vector2D_Normalize(self:GetXY()));
return self;
end
function Vector2DMixin:RotateDirection(rotationRadians)
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
return self;
end
function Vector2DMixin:Clone()
return CreateVector2D(self:GetXY());
end
+173
View File
@@ -0,0 +1,173 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector3D_ScaleBy(scalar, x, y, z)
return x * scalar, y * scalar, z * scalar;
end
function Vector3D_DivideBy(divisor, x, y, z)
return x / divisor, y / divisor, z / divisor;
end
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX + rightX, leftY + rightY, leftZ + rightZ;
end
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX - rightX, leftY - rightY, leftZ - rightZ;
end
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
end
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX * rightX + leftY * rightY + leftZ * rightZ;
end
function Vector3D_GetLengthSquared(x, y, z)
return Vector3D_Dot(x, y, z, x, y, z);
end
function Vector3D_GetLength(x, y, z)
return sqrt(Vector3D_GetLengthSquared(x, y, z));
end
function Vector3D_Normalize(x, y, z)
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
end
function Vector3D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector3D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector3D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector3D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
return cos(-pitch) * cos(yaw),
cos(-pitch) * sin(yaw),
sin(-pitch);
end
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
if x ~= 0 or y ~= 0 then
return atan2(y, x), asin(-z);
end
return 0, asin(-z);
end
function Vector3D_CalculateYawPitchFromNormalVector(vector)
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
end
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
end
Vector3DMixin = {};
function CreateVector3D(x, y, z)
local vector = CreateFromMixins(Vector3DMixin);
vector:OnLoad(x, y, z);
return vector;
end
function AreVector3DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector3DMixin:OnLoad(x, y, z)
self:SetXYZ(x, y, z);
end
function Vector3DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z;
end
function Vector3DMixin:GetXYZ()
return self.x, self.y, self.z;
end
function Vector3DMixin:SetXYZ(x, y, z)
self.x = x;
self.y = y;
self.z = z;
end
function Vector3DMixin:ScaleBy(scalar)
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:DivideBy(scalar)
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:Add(other)
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Subtract(other)
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Cross(other)
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Dot(other)
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
end
function Vector3DMixin:GetLengthSquared()
return Vector3D_GetLengthSquared(self:GetXYZ());
end
function Vector3DMixin:GetLength()
return Vector3D_GetLength(self:GetXYZ());
end
function Vector3DMixin:Normalize()
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
return self;
end
function Vector3DMixin:Clone()
return CreateVector3D(self:GetXYZ());
end
+143
View File
@@ -0,0 +1,143 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector4D_ScaleBy(scalar, x, y, z, w)
return x * scalar, y * scalar, z * scalar, w * scalar;
end
function Vector4D_DivideBy(divisor, x, y, z, w)
return x / divisor, y / divisor, z / divisor, w / divisor;
end
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
end
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
end
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
end
function Vector4D_GetLengthSquared(x, y, z, w)
return Vector4D_Dot(x, y, z, w, x, y, z, w);
end
function Vector4D_GetLength(x, y, z, w)
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
end
function Vector4D_Normalize(x, y, z, w)
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
end
function Vector4D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector4D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector4D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector4D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
Vector4DMixin = {};
function CreateVector4D(x, y, z, w)
local vector = CreateFromMixins(Vector4DMixin);
vector:OnLoad(x, y, z, w);
return vector;
end
function AreVector4DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector4DMixin:OnLoad(x, y, z, w)
self:SetXYZW(x, y, z, w);
end
function Vector4DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z
and self.w == otherVector.w;
end
function Vector4DMixin:GetXYZW()
return self.x, self.y, self.z, self.w;
end
function Vector4DMixin:SetXYZW(x, y, z, w)
self.x = x;
self.y = y;
self.z = z;
self.w = w;
end
function Vector4DMixin:ScaleBy(scalar)
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:DivideBy(scalar)
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:Add(other)
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Subtract(other)
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Dot(other)
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
end
function Vector4DMixin:GetLengthSquared()
return Vector4D_GetLengthSquared(self:GetXYZW());
end
function Vector4DMixin:GetLength()
return Vector4D_GetLength(self:GetXYZW());
end
function Vector4DMixin:Normalize()
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
return self;
end
function Vector4DMixin:Clone()
return CreateVector4D(self:GetXYZW());
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 (button:IsMouseOver()) 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 });
+3 -1
View File
@@ -118,7 +118,9 @@ target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
# CopyToClipboard in src/clipboard/Copy.cpp.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+9 -6
View File
@@ -23,6 +23,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
@@ -34,11 +35,12 @@ 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` (script) |
| [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` |
@@ -46,7 +48,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `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`, `math.fmod`, `math.huge`, `math.modf`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.wipe` |
| [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` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -57,10 +59,10 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [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` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [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.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`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [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` |
| [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` |
@@ -68,7 +70,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [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.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` |
@@ -89,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` |
+566 -99
View File
@@ -34,6 +34,16 @@ build instructions.
- [Class](#class)
- [`FillLocalizedClassList(table [, isFemale])`](#filllocalizedclasslisttable-isfemale)
- [ColorUtil](#colorutil)
- [`C_ColorUtil.ConvertRGBToHSV(r, g, b)`](#c_colorutilconvertrgbtohsvr-g-b)
- [`C_ColorUtil.ConvertHSVToRGB(h, s, v)`](#c_colorutilconverthsvtorgbh-s-v)
- [`C_ColorUtil.ConvertHSVToHSL(h, s, v)`](#c_colorutilconverthsvtohslh-s-v)
- [`C_ColorUtil.ConvertHSLToHSV(h, s, l)`](#c_colorutilconverthsltohsvh-s-l)
- [`C_ColorUtil.ConvertHSLToRGB(h, s, l)`](#c_colorutilconverthsltorgbh-s-l)
- [`C_ColorUtil.GenerateTextColorCode(color)`](#c_colorutilgeneratetextcolorcodecolor)
- [`C_ColorUtil.WrapTextInColor(text, color)`](#c_colorutilwraptextincolortext-color)
- [`C_ColorUtil.WrapTextInColorCode(text, colorCode)`](#c_colorutilwraptextincolorcodetext-colorcode)
- [Combat](#combat)
- [`InCombatLockdown()`](#incombatlockdown)
@@ -145,10 +155,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)
@@ -166,7 +180,7 @@ build instructions.
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`](#gametooltipsethyperlinkcompareitemitemlink--offset-shiftbutton-comparisontooltip)
- [`GameTooltip:IsEquippedItem()`](#gametooltipisequippeditem)
- [`OnTooltipSetItem` script](#ontooltipsetitem-script)
- [`OnTooltipSet*` scripts (Item / Spell / Unit / GameObject)](#ontooltipset-scripts)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:SetTotem(slot)`](#gametooltipsettotemslot)
@@ -188,6 +202,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)
@@ -290,6 +308,8 @@ build instructions.
- [Lua](#lua)
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
- [`table.count(tbl)`](#tablecounttbl)
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject--createfrommixins)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
@@ -379,9 +399,6 @@ build instructions.
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
- [Screen](#screen)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [Spell](#spell)
- [`C_Spell.DoesSpellExist(spellID)`](#c_spelldoesspellexistspellid)
- [`C_Spell.GetSchoolString(schoolMask)`](#c_spellgetschoolstringschoolmask)
@@ -393,6 +410,7 @@ build instructions.
- [`C_Spell.GetSpellLink(spellID)`](#c_spellgetspelllinkspellid)
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
- [`C_Spell.GetSpellEffectMechanics(spellID)`](#c_spellgetspelleffectmechanicsspellid)
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
@@ -400,6 +418,7 @@ build instructions.
- [`IsPassiveSpell(spellID)` / `IsPassiveSpell(slot, bookType)`](#ispassivespellspellid--ispassivespellslot-booktype)
- [`C_Spell.IsSpellPassive(spellID)`](#c_spellisspellpassivespellid)
- [`IsPlayerSpell(spellID)`](#isplayerspellspellid)
- [`CanDualWield()`](#candualwield)
- [`IsSpellKnown(spellID, [isPet])`](#isspellknownspellid-ispet)
- [`GetSpellBonusDamage(school)`](#getspellbonusdamageschool)
- [`GetSpellBonusHealing()`](#getspellbonushealing)
@@ -447,9 +466,13 @@ build instructions.
- [`GetShapeshiftFormID()`](#getshapeshiftformid)
- [`CancelShapeshiftForm()`](#cancelshapeshiftform)
- [System](#system)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [`CopyToClipboard(text [, removeMarkup])`](#copytoclipboardtext--removemarkup)
- [Talent](#talent)
- [`GetTalentSpellID(tabIndex, talentIndex, [rank])`](#gettalentspellidtabindex-talentindex-rank)
- [`GetTalentIDByIndex(tabIndex, talentIndex)`](#gettalentidbyindextabindex-talentindex)
- [`GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`](#gettalentspellidtabindex-talentindex-rank-classid)
- [`GetTalentIDByIndex(tabIndex, talentIndex[, classID])`](#gettalentidbyindextabindex-talentindex-classid)
- [Targeting](#targeting)
- [`GetPlayerFacing()`](#getplayerfacing)
@@ -514,6 +537,7 @@ build instructions.
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitPosition(unit)`](#unitpositionunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`ClosestUnitPosition(creatureID)`](#closestunitpositioncreatureid)
- [`UnitHealthMissing(unit)`](#unithealthmissingunit)
@@ -1058,6 +1082,80 @@ Sparse class IDs (vanilla skips classID 6 — Death Knight didn't
exist yet — and a few others) have NULL records and are silently
skipped.
## ColorUtil
The modern `C_ColorUtil` color-space and text-color-code helpers. All are
pure functions (no engine state); conventions match a retail-family client
that ships them natively, verified in-game.
**Ranges:** hue is in **degrees**, `[0, 360)`; saturation / value / lightness
are `[0, 1]`. RGB channels are `[0, 1]` (not 0–255). Achromatic (gray) inputs
return a hue of **`-1`** — a sentinel, not `0` — which the reverse conversions
accept as "no hue" (equivalent to `saturation == 0`).
The `color` argument is a table with `r`, `g`, `b` fields in `[0, 1]`
(`ColorMixin`-shaped). An optional `a` is honored (default `1`), though the
color code always encodes 8 hex digits with alpha.
### `C_ColorUtil.ConvertRGBToHSV(r, g, b)`
Returns `(h, s, v)`. Achromatic input → `h == -1`.
```lua
C_ColorUtil.ConvertRGBToHSV(0, 1, 0) -- 120, 1, 1 (pure green)
C_ColorUtil.ConvertRGBToHSV(0.5, 0.5, 0.5) -- -1, 0, 0.5 (gray)
```
### `C_ColorUtil.ConvertHSVToRGB(h, s, v)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is treated as achromatic
and yields `r == g == b == v`.
```lua
C_ColorUtil.ConvertHSVToRGB(120, 1, 1) -- 0, 1, 0
```
### `C_ColorUtil.ConvertHSVToHSL(h, s, v)`
Returns `(h, s, l)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToHSV(h, s, l)`
Returns `(h, s, v)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToRGB(h, s, l)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is achromatic → `r == g ==
b == l`.
### `C_ColorUtil.GenerateTextColorCode(color)`
Returns the bare 8-hex color string `"AARRGGBB"` (alpha first), **not**
including the `|c` escape.
```lua
C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0}) -- "ffff0000"
```
### `C_ColorUtil.WrapTextInColor(text, color)`
Wraps `text` in the color's escape sequence: `"|c" .. AARRGGBB .. text ..
"|r"`.
```lua
C_ColorUtil.WrapTextInColor("Hi", {r = 1, g = 0, b = 0}) -- "|cffff0000Hi|r"
```
### `C_ColorUtil.WrapTextInColorCode(text, colorCode)`
Wraps `text` in the given bare color code: `"|c" .. colorCode .. text ..
"|r"`. Pair with `GenerateTextColorCode` (which produces `colorCode`).
```lua
local code = C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0})
C_ColorUtil.WrapTextInColorCode("Hi", code) -- "|cffff0000Hi|r"
```
## Combat
### `InCombatLockdown()`
@@ -3504,6 +3602,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
@@ -3552,6 +3696,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)`
@@ -4020,7 +4180,7 @@ Classic. The anchor string is reachable via vanilla's native
Renders a tooltip for the talent identified by `Talent.dbc` primary
key — the natural pair to
[`GetTalentIDByIndex`](#gettalentidbyindextabindex-talentindex).
[`GetTalentIDByIndex`](#gettalentidbyindextabindex-talentindex-classid).
Works for any class's talents, not just the player's.
Two-tier resolution:
@@ -4158,18 +4318,27 @@ GameTooltip:SetBagItem(0, 1)
if GameTooltip:IsEquippedItem() then ... end -- false unless that item is also worn
```
### `OnTooltipSetItem` script
### `OnTooltipSet*` scripts
A real frame script — settable with the standard `SetScript` / `GetScript` /
`HookScript` — that fires whenever a tooltip's **item** is set. Backports the
modern tooltip script so addons annotate item tooltips by hooking one script
instead of wrapping every `Set*` method.
Fires for every item-setting path (`SetBagItem`, `SetInventoryItem`,
`SetHyperlink` for an `item:` link, `SetMerchantItem`, `SetAuctionItem`,
`SetItemByID`, …). Available on all GameTooltip-type frames (`GameTooltip`,
`OnTooltipSetItem` / `OnTooltipSetSpell` / `OnTooltipSetUnit` /
`OnTooltipSetGameObject` — real frame scripts, settable with the standard
`SetScript` / `GetScript` /
`HookScript` — that fire whenever a tooltip's **item**, **spell**, **unit**, or
**gameobject** is set. Backport the modern tooltip scripts so addons annotate
tooltips by hooking one script per object type instead of wrapping every
`Set*` method. Available on all GameTooltip-type frames (`GameTooltip`,
`ItemRefTooltip`, `ShoppingTooltip1/2`, `AtlasLootTooltip`, …).
| Script | Fires after any of |
|---|---|
| `OnTooltipSetItem` | `SetBagItem`, `SetInventoryItem`, `SetHyperlink` (`item:`), `SetMerchantItem`, `SetAuctionItem`, `SetItemByID`, … |
| `OnTooltipSetSpell` | `SetSpell`, `SetSpellByID`, `SetTalent`, `SetShapeshift` |
| `OnTooltipSetUnit` | `SetUnit` and unit mouseover |
| `OnTooltipSetGameObject` | gameobject mouseover (herb/ore nodes, chests, mailboxes, signs) |
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; these four are the backported set.
The handler receives the tooltip as the **global `this`**, the 1.12
frame-script convention — *not* a `self` argument (like every built-in vanilla
script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
@@ -4178,21 +4347,25 @@ script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
```lua
GameTooltip:HookScript("OnTooltipSetItem", function()
local name, link, id = this:GetItem() -- the tooltip is the global `this`
if id then
this:AddLine("ID: " .. id, 0.6, 0.6, 0.6)
end
if id then this:AddLine("ID: " .. id, 0.6, 0.6, 0.6) end
end)
GameTooltip:HookScript("OnTooltipSetUnit", function()
local name, unit = this:GetUnit()
if unit then this:AddLine(UnitClassBase(unit), 0.6, 0.6, 0.6) end
end)
```
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; `OnTooltipSetSpell` / `OnTooltipSetUnit` are not
yet provided.
**Auras don't fire `OnTooltipSetSpell`** — `SetUnitBuff` / `SetUnitDebuff` /
`SetPlayerBuff` / `SetUnitAura` use a separate aura-tooltip builder, so hovering
a buff/debuff does not trigger the spell script. This matches retail, where unit
auras are a distinct tooltip data type rather than a spell.
**Caveat — the event fires from inside the item-tooltip build.** Lightweight
handler work is fine: reading `this:GetItem()`, `this:AddLine(...)`, printing,
etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because
the handler runs mid-build, re-entering the tooltip / FrameScript machinery can
**Caveat — the event fires from inside the tooltip build.** Lightweight handler
work is fine: reading `this:GetItem()` / `this:GetUnit()`, `this:AddLine(...)`,
printing, etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because the
handler runs mid-build, re-entering the tooltip / FrameScript machinery can
collide with other DLLs that hook the same Lua paths (nampower, SuperWoW,
weirdutils, …) and crash the client. If you need heavy work like that, defer it
to the next frame:
@@ -4539,6 +4712,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` /
@@ -7115,6 +7329,55 @@ undefined per the Lua reference manual.
Errors on non-table input.
### `table.count(tbl)`
Returns `(numTableNodes, numArrayNodes, maxArrayIndex)` describing how a table
is populated (a modern WoW diagnostic, added retail 11.2.5). These are counts
of live entries, **not** the table's allocated capacity:
- **`numTableNodes`** — total number of key/value pairs.
- **`numArrayNodes`** — how many of those have an integer key in the range
`[1..numTableNodes]` (the "contiguous array part" heuristic).
- **`maxArrayIndex`** — the largest positive integral key (`>= 1`), or `0` if
there is none. Negative/zero integral keys don't count.
```lua
table.count({ 10, 20, 30 }) -- 3, 3, 3
table.count({ a = 1, b = 2 }) -- 2, 0, 0
table.count({ [1] = "x", foo = "y" }) -- 2, 1, 1
table.count({ [100] = "x" }) -- 1, 0, 100
table.count({ [-3] = "x" }) -- 1, 0, 0
```
Pure iteration over the table (`lua_next`), so no dependency on Lua's internal
storage layout. Errors on non-table input.
### `Mixin(object, ...)` / `CreateFromMixins(...)`
The FrameXML table-mixin primitives, provided as engine C functions.
- **`Mixin(object, ...)`** — shallow-copies every key/value from each table
argument onto `object` (later arguments win on key collisions) and returns
`object`. Non-table arguments are skipped.
- **`CreateFromMixins(...)`** — `Mixin({}, ...)`: returns a new table seeded
from the given mixins.
```lua
local Greeter = {}
function Greeter:Hello() return "hi, "..self.name end
local obj = CreateFromMixins(Greeter)
obj.name = "Bob"
obj:Hello() -- "hi, Bob"
```
Modern WoW provides these as C functions in `TableUtil`; ClassicAPI does the
same (rather than leaving them in the `!!!ClassicAPI` addon) so they exist
whenever the DLL is injected — even if the addon is disabled. The composite
`CreateAndInitFromMixin(mixin, ...)` (which calls a `:Init` method) remains a
Lua helper in the addon, mirroring retail's `Mixin.lua`. The taint-based
`SecureMixin` family is intentionally omitted — vanilla 1.12 has no execution
taint system, so there is nothing for it to guard.
### `string.match` / `string.gmatch`
The Lua 5.1 pattern helpers, backported to 1.12's Lua 5.0 (which ships only
@@ -9181,40 +9444,6 @@ A separate function keeps the existing call wire-compatible.
> first, skip zero slots, then the item array. 1-based `objectiveIndex`
> counts only non-empty slots.
## Screen
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
## Spell
> **All `C_Spell.*` functions in this section accept any spell
@@ -9523,6 +9752,41 @@ column is exactly what this function returns):
> `14` (incapacitated) in 1.12 — the `30` ("sapped") value used by some
> addon tables is a later-expansion addition and has no row here.
### `C_Spell.GetSpellEffectMechanics(spellID)`
Returns the spell's three per-effect `SpellMechanic` ids (`Spell.dbc`
`EffectMechanic[3]`) as a 1-based array table, or `nil` for an invalid /
out-of-range spell ID. Each entry uses the same numbering as
[`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid) (`0` = that
effect carries no mechanic).
```lua
C_Spell.GetSpellEffectMechanics(1822) -- Rake → { 0, 15, 0 }
C_Spell.GetSpellEffectMechanics(703) -- Garrote → { 0, 0, 0 }
C_Spell.GetSpellEffectMechanics(133) -- Fireball → { 0, 0, 0 }
```
Complements [`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid),
which reads only the **spell-level** `Mechanic` field (`+0x14`). Vanilla
frequently stores a spell's mechanic on an **effect** instead — periodic
damage such as bleeds is the common case. Garrote/Rupture/Rend/Rip tag
`bleeding` (15) at the spell level, but **Rake** has spell-level `0` and
`EffectMechanic[2] = 15`, so effect-mechanic-aware callers (e.g. bleed
classification for immunity tracking) need this array:
```lua
local function IsBleed(spellID)
if C_Spell.GetSpellMechanicByID(spellID) == 15 then return true end
local em = C_Spell.GetSpellEffectMechanics(spellID)
if em then for i = 1, 3 do if em[i] == 15 then return true end end end
return false
end
```
Reads `Spell.dbc` directly (`EffectMechanic[3]` at `+0x13C`), so it covers
every spell the client knows — not just the player's spellbook — with no
caching or network round-trip.
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
@@ -9703,6 +9967,23 @@ Reads a single bit from the engine's spell-knowledge bitmap at
The same lookup the engine itself does internally. No spellbook walk,
no talent walk, no profession-window dependency.
### `CanDualWield()`
Returns `true` if the player can equip a weapon in the off hand.
```lua
if CanDualWield() then ... end -- true for a warrior/rogue past level 10, etc.
```
Server-side this is a plain flag (`Player::m_canDualWield`) that starts
`false` and is turned on **only** by the `SPELL_EFFECT_DUAL_WIELD` spell
effect — no class has it innately. Exactly one spell in the 1.12 client's
`Spell.dbc` carries that effect: `674` ("Dual Wield"), the trained
warrior / rogue / hunter passive, which lands in the known-spell bitmap
like any learned spell. So `CanDualWield()` is precisely
`IsPlayerSpell(674)` — there is no separate client-visible capability
field to read.
### `IsSpellKnown(spellID, [isPet])`
Returns `true` if the given spellID is currently in the player's (or
@@ -10305,34 +10586,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).
@@ -11019,9 +11313,81 @@ Mirrors 3.3.5a's `Script_CancelShapeshiftForm` inner (`FUN_00726CE0`),
which does the same effect-array scan + form-id match before issuing
its cancel packet. Vanilla just lacks the public Lua surface.
## System
Host/OS-level helpers that aren't tied to a game domain.
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
### `CopyToClipboard(text [, removeMarkup])`
Copies `text` to the Windows clipboard and returns the number of bytes
copied. Vanilla has no clipboard access at all, so this is a ClassicAPI
addition backed by the Win32 clipboard API. Available on both the in-game
and login/character-select screens.
- `text` — the string to copy (numbers are coerced, like Lua does).
- `removeMarkup` — optional; when truthy, strip WoW UI escape sequences
before copying so the clipboard gets plain text:
- `|cAARRGGBB … |r` color codes → removed
- `|H…|h text |h` hyperlinks → keeps only the visible text
- `|T…|t` textures → removed
- `|n` → newline, `||` → literal `|`
- unknown `|x` sequences are left verbatim, so real text that merely
contains a pipe survives
- returns the byte length of the copied (possibly stripped) string —
consistent with Lua's `#s`. `0` for an empty string or if the clipboard
couldn't be opened.
```lua
CopyToClipboard("hello world") -- 11
local link = "\124cffff0000Red\124r" -- real single-pipe markup
CopyToClipboard(link, true) -- 3 (clipboard gets "Red")
CopyToClipboard(link) -- 15 (raw escapes copied)
```
> The string is treated as UTF-8 (matching the credential and TTS modules)
> and written as `CF_UNICODETEXT`; Windows synthesizes `CF_TEXT` for legacy
> consumers.
>
> Note when testing from the chat box: the edit box escapes every typed `|`
> into `||`, so a literal `|cff…` you type is no longer a color code by the
> time it reaches Lua. Build real markup with `\124` (as above) to exercise
> `removeMarkup`.
## Talent
### `GetTalentSpellID(tabIndex, talentIndex, [rank])`
### `GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`
Returns the spellID for the talent at the given `(tabIndex, talentIndex)`
and rank, or `nil` if the talent index is out of range or the rank slot
@@ -11035,6 +11401,7 @@ GetTalentSpellID(1, 9) -- 14751 (Inner Focus, single rank)
GetTalentSpellID(1, 1) -- 14525 (Wand Specialization, current rank)
GetTalentSpellID(1, 1, 1) -- 14524 (Wand Specialization rank 1)
GetTalentSpellID(1, 1, 2) -- 14525 (Wand Specialization rank 2)
GetTalentSpellID(1, 1, 1, 8) -- 11210 (Arcane Subtlety r1 — Mage, classID 8)
-- chains into the spell APIs cleanly
GameTooltip:SetSpellByID(GetTalentSpellID(1, 9))
@@ -11054,32 +11421,44 @@ of how many points the player has invested. Useful for tooltip-on-hover
scenarios where you want to preview "what rank 5 would do" without
respec'ing.
`classID` is optional (4th arg; pass `rank` as nil to keep the default)
and queries any class's talent tree via `Talent.dbc` / `TalentTab.dbc` —
the same cross-class path as
[`GetTalentIDByIndex`](#gettalentidbyindextabindex-talentindex-classid),
with identical `(tab, idx)` ordering. Because there's no "currentRank"
for a class you aren't, a cross-class query with no explicit `rank`
defaults to **rank 1** (rather than the player-currentRank default of
the same-class path).
Returns `nil` for:
- non-numeric or non-positive `tabIndex` / `talentIndex`
- `tabIndex` or `talentIndex` out of range for the player's class
- `tabIndex` or `talentIndex` out of range for the class
- explicit `rank` exceeding the talent's allocated max — e.g. asking
for rank 5 on a 1-rank talent like Inner Focus
- a `classID` that matches no class
Reads the engine's per-tab talent arrays at `[0x00BDCD28]` (populated
at login from `Talent.dbc` filtered by class). The `SpellRank[]` array
lives at offset `+0x10` of each `TalentEntry` (stride `0x54`), with
one spellID per rank — vanilla populates indices 0..4 (ranks 1..5),
the higher slots stay zero.
Without `classID`, reads the engine's per-tab talent arrays at
`[0x00BDCD28]` (populated at login from `Talent.dbc` filtered by the
player's class); with it, reads the `Talent.dbc` flat array directly.
Either way the `SpellRank[]` array lives at offset `+0x10` of each
record (stride `0x54`), one spellID per rank — vanilla populates
indices 0..4 (ranks 1..5), the higher slots stay zero.
Equivalent to one of `GetTalentInfo`'s extended returns in modern WoW
(varies by version; the talent's spellID has been part of the tuple
since 5.0+).
### `GetTalentIDByIndex(tabIndex, talentIndex)`
### `GetTalentIDByIndex(tabIndex, talentIndex[, classID])`
Returns the engine's hidden talentID — the primary key of the
`Talent.dbc` row — for the talent at the given (tab, idx). Returns
`nil` for out-of-range indices.
```lua
GetTalentIDByIndex(1, 9) -- 174 (Inner Focus, Discipline tier 3)
GetTalentIDByIndex(1, 1) -- 166 (first Discipline talent)
GetTalentIDByIndex(1, 9) -- 174 (Inner Focus, Discipline tier 3)
GetTalentIDByIndex(1, 1) -- 166 (first Discipline talent)
GetTalentIDByIndex(1, 1, 8) -- 37 (first Arcane talent — Mage, classID 8)
```
1.12's `GetTalentInfo(tab, idx)` returns
@@ -11089,9 +11468,21 @@ key for `GetTalentInfoByID`, talent build sharing strings, etc.); we
add this getter so addons that key on talentIDs from later expansions
work unmodified.
Reads `TalentEntry+0x00` from the per-tab talent arrays at
`[0x00BDCD28]`. Same struct walk as `GetTalentSpellID`, just reads
a different field.
The optional `classID` (1-based: Warrior 1, Paladin 2, Hunter 3,
Rogue 4, Priest 5, Shaman 7, Mage 8, Warlock 9, Druid 11) queries any
class's talent tree, not just the local player's — useful for
talent-build tooling and previewing other specs. When omitted or nil,
it falls back to the player's own class.
- **Without `classID`** — reads `TalentEntry+0x00` from the per-tab
talent arrays at `[0x00BDCD28]` (the engine's runtime state for the
local player's class). Same struct walk as `GetTalentSpellID`.
- **With `classID`** — reads the `Talent.dbc` / `TalentTab.dbc` flat
arrays directly, replicating the engine's tree builder
(`FUN_004f2c00`): tabs in DBC row order filtered by class-mask, then
talents in DBC row order within each tab. This is the *same* ordering
the runtime arrays are built from, so `GetTalentIDByIndex(t, i)` and
`GetTalentIDByIndex(t, i, <player's class>)` return identical values.
Equivalent to the talentID return slot of `GetTalentInfo` in modern
WoW (5.0+; not exposed at all in 1.12).
@@ -12218,17 +12609,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`
@@ -12521,13 +12953,19 @@ Backport of the modern `C_UnitAuras` namespace. Returns
`local d = C_UnitAuras.GetAuraDataByIndex(unit, 1); if d.dispelName ==
"Magic" then ...` works unchanged.
Reads everything off the unit's `m_objectFields` descriptor — same
Reads primarily off the unit's `m_objectFields` descriptor — same
data source `UnitBuff` / `UnitDebuff` use. The descriptor has 48 aura
slots total: 32 helpful (buffs) at indices 0..31, 16 harmful
(debuffs) at indices 32..47. Functions in this namespace take a
1-based Lua index that translates onto whichever range the filter
selects.
When a party/raid member has **no live unit object at all** (a
different map, far out of range), there is no descriptor to read — but
the server still transmits that member's current aura spell IDs, and
these functions surface them, exactly as the built-in `UnitBuff` /
`UnitDebuff` do. See [Out-of-range group members](#out-of-range-group-members).
### `AuraData` table shape
| Field | Type | Source / value on vanilla |
@@ -12613,6 +13051,35 @@ Implications:
- Entries are evicted once their timed aura elapses, so the cache stays
bounded; infinite-duration auras persist until overwritten under load.
### Out-of-range group members
The descriptor and the `Aura::Source` cache both require the member's
CGUnit to exist on the client. When a party/raid member is on a
**different map** (or otherwise far enough out of range that the engine
drops their object entirely), neither is available — yet the server keeps
every group member's current auras flowing in `SMSG_PARTY_MEMBER_STATS`
(`GROUP_UPDATE_FLAG_AURAS` for buffs, `GROUP_UPDATE_FLAG_AURAS_NEGATIVE`
for debuffs), and the client stashes them in the group-member roster
structs. Every `C_UnitAuras.*` function falls back to that array when the
unit has no object — the same source the built-in `UnitBuff` / `UnitDebuff`
read out of range — so a cross-map `GetUnitAuras("raid7")` returns the
member's real buffs/debuffs instead of an empty table.
This path is **spell-ID only**, because that is all the packet carries:
- `applications` is always `1` — stack counts aren't transmitted.
- `expirationTime` / `sourceUnit` / `sourceGUID` are present only if *you*
cast the aura (the `Aura::Source` `SMSG_SPELL_GO` entry is merged in when
it matches); otherwise the usual defaults.
- `duration` is the `Spell.dbc` base with level scaling.
- Spell IDs are truncated to 16 bits on the wire, so a custom aura with a
spell ID above 65535 comes through wrong — an inherent limitation of the
vanilla packet that `UnitBuff` shares.
Distinct from the in-range descriptor-drop cases (rogue stealth, nearby
range fluctuation), where the object still exists and the `Aura::Source`
cache is the fallback — see [Caster & timing](#caster--timing-aurasource).
### `C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`
Returns the `AuraData` table for the `index`-th aura (1-based)
+132
View File
@@ -140,6 +140,23 @@ enum Offsets {
// and call the inner invoker, which is self-contained.
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
// The other per-object tooltip builders, co-hooked the same way as
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
// its Set*/mouseover paths converge on and clears the tooltip
// (FUN_00530050) before repopulating, so firing after it covers every way
// that object type gets set:
// - Spell FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP (0x0052E610, 7 stack args,
// RET 0x1c) — SetSpell/SetSpellByID/SetTalent/SetShapeshift funnel.
// - Unit (0x00529fe0, __thiscall(self, guid*), RET 4) — SetUnit +
// the two engine mouseover paths (FUN_004919d0 / FUN_00492890).
// Returns an int the caller (Script_GameTooltip_SetUnit) tests, so the
// co-hook must forward the original's return value.
// - GameObject (0x0052aa20, __thiscall(self, guid*), RET 4) — the GO
// hover populator; resolves the GO (OBJ_TYPE_GAMEOBJECT) and writes the
// GO field at +0x370.
FUN_GAMETOOLTIP_BUILD_UNIT = 0x00529FE0,
FUN_GAMETOOLTIP_BUILD_GAMEOBJECT = 0x0052AA20,
FUN_GAMETOOLTIP_ADD_LINE = 0x00530270, // __thiscall(self, left, right, lColorBGRA*, rColorBGRA*, wrap)
OFF_GAMETOOLTIP_NUM_LINES = 0x31C, // int — live line count (AddLine index; +0x320 is the cap)
// The displayed item's identity is read via the existing
@@ -1297,12 +1314,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,
@@ -3779,6 +3826,60 @@ enum Offsets {
GROUP_MEMBER_STATUS_ONLINE = 0x1,
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
// Health/power fields within the two group-member blocks — the
// out-of-range fallback the engine's UnitHealth/UnitMana (and their Max
// variants) use when a groupmate has no live CGUnit (different map / out
// of range), sourced from SMSG_PARTY_MEMBER_STATS. The two blocks have
// DIFFERENT layouts (all u16):
//
// Stats block (FUN_GROUP_MEMBER_STATS_LOOKUP): statusFlags@+8,
// powerType@+9, health@+0xa, maxHealth@+0xc, power@+0xe, maxPower@+0x10,
// level@+0x12, areaId@+0x14. The full record.
// Raid slot (FUN_GROUP_MEMBER_SLOT_LOOKUP): powerType@+0x58,
// health@+0x5c, maxHealth@+0x5e, power@+0x60, maxPower@+0x62. Leaner —
// no level/area/flags.
//
// Power values are raw (divide by the power divisor for display). Verified
// from Script_UnitHealth (0x005174d0) / Script_UnitHealthMax (0x005175b0)
// / Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
OFF_GROUP_MEMBER_STATS_HEALTH = 0xa,
OFF_GROUP_MEMBER_STATS_MAX_HEALTH = 0xc,
OFF_GROUP_MEMBER_STATS_POWER_TYPE = 0x9,
OFF_GROUP_MEMBER_STATS_POWER = 0xe,
OFF_GROUP_MEMBER_STATS_MAX_POWER = 0x10,
OFF_GROUP_MEMBER_STATS_LEVEL = 0x12,
OFF_RAID_SLOT_POWER_TYPE = 0x58,
OFF_RAID_SLOT_HEALTH = 0x5c,
OFF_RAID_SLOT_MAX_HEALTH = 0x5e,
OFF_RAID_SLOT_POWER = 0x60,
OFF_RAID_SLOT_MAX_POWER = 0x62,
// Remaining raid-member-struct fields, mapped from Script_GetRaidRosterInfo
// (0x004bb560). Same struct FUN_GROUP_MEMBER_SLOT_LOOKUP returns; all
// object-independent (readable for out-of-range raiders). Name + class are
// NOT here — GetRaidRosterInfo reads those from the NameCache by GUID.
OFF_RAID_SLOT_SUBGROUP = 0x08, // u32, 0-based (roster UI shows +1)
OFF_RAID_SLOT_RANK = 0x0c, // u32
OFF_RAID_SLOT_FLAGS = 0x18, // u32
OFF_RAID_SLOT_LEVEL = 0x22, // u16
OFF_RAID_SLOT_AREA_ID = 0x24, // u16 -> AreaTable.dbc
RAID_SLOT_FLAG_ONLINE = 0x1,
RAID_SLOT_FLAG_DEAD = 0x4,
// Group-member aura spell-ID arrays — the out-of-range fallback the
// engine's Script_UnitBuff (0x00519500) / Script_UnitDebuff (0x005198f0)
// read when a groupmate has no live CGUnit. The server transmits a
// member's current auras (spell IDs only — no caster/duration/stacks) in
// SMSG_PARTY_MEMBER_STATS via GROUP_UPDATE_FLAG_AURAS (buffs, slots 0..31)
// + GROUP_UPDATE_FLAG_AURAS_NEGATIVE (debuffs, slots 32..47); the client
// keeps them in the two group-member blocks. Both arrays are 48 u16
// entries (UNIT_AURA_TOTAL) indexed by the same absolute slot the
// descriptor uses. Spell IDs are truncated to u16 on the wire, so custom
// IDs > 65535 come through wrong (same limitation as the built-in
// UnitBuff/UnitDebuff). Read the party array only when the member's online
// flag (OFF_GROUP_MEMBER_STATUS_FLAGS bit 0) is set — the engine does.
OFF_GROUP_MEMBER_STATS_AURAS = 0x1a, // party stats block (FUN_00496400)
OFF_RAID_SLOT_AURAS = 0x64, // raid slot (FUN_00496420)
// Per-slot GUID tables — used to map party/raid token strings
// ("party1", "raid17") to GUIDs without going through
// `FUN_RESOLVE_UNIT_TOKEN`, which only resolves to a local CGUnit
@@ -4355,6 +4456,31 @@ enum Offsets {
VAR_TALENT_DBC_RECORDS = 0x00C0D6E8,
VAR_TALENT_DBC_COUNT = 0x00C0D6EC,
// Flat (row-order) DBC arrays used by the talent-tree builder
// `FUN_004f2c00` — DISTINCT from the sparse by-ID index above. The builder
// walks TalentTab.dbc in row order, filtering each tab by
// `FUN_004f2e50(race, class, raceMask@+0x2c, classMask@+0x30)` (a plain
// bitmask test; mask 0 = all), then walks Talent.dbc in row order grouping
// contiguous TabID runs — so `GetTalentInfo(tab, idx)` ordering IS just
// "TalentTab rows for the class in row order" × "Talent.dbc rows for that
// TabID in row order". Reading these arrays with the same walk (but an
// arbitrary class-mask) reproduces the engine's ordering for any class,
// which is how `GetTalentIDByIndex`'s optional classID arg works.
//
// Each global holds a base POINTER (deref) to the contiguous record array;
// the sibling +4 holds the record count. Strides match the by-ID records:
// TalentTab 0x3c, Talent 0x54 (TALENT_ENTRY_STRIDE).
// TalentTab record: ID@+0x00, raceMask@+0x2c, classMask@+0x30
// Talent record: ID@+0x00, TabID@+0x04 (rest per the by-ID layout)
VAR_TALENTTAB_DBC_FLAT_RECORDS = 0x00C0D6CC,
VAR_TALENTTAB_DBC_FLAT_COUNT = 0x00C0D6D0,
TALENTTAB_ENTRY_STRIDE = 0x3c,
OFF_TALENTTAB_ID = 0x00,
OFF_TALENTTAB_CLASS_MASK = 0x30,
VAR_TALENT_DBC_FLAT_RECORDS = 0x00C0D6E0,
VAR_TALENT_DBC_FLAT_COUNT = 0x00C0D6E4,
OFF_TALENT_DBC_TAB_ID = 0x04,
// Engine's `Script_GetTalentInfo` Lua C function. We call it from
// `GetTalentSpellID` to derive the player's currentRank without
// re-implementing the spell-knowledge checks at `0x0060C740` /
@@ -5242,6 +5368,12 @@ enum Offsets {
// (form ID for shapeshift, channel index for periodic, etc.).
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
// Per-effect SpellMechanic ids (EffectMechanic[3]). One int32[3] (0xC)
// after EffectBasePoints (0x130); cross-checked against EffectRadiusIndex
// (0x160) and EffectApplyAuraName (0x16C). Vanilla often stores a spell's
// mechanic on an effect rather than the spell-level Mechanic (0x14), so
// bleed / DoT detection needs this array in addition to that field.
OFF_SPELL_RECORD_EFFECT_MECHANIC = 0x13C, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
+70 -25
View File
@@ -29,6 +29,7 @@
#include "Game.h"
#include "Offsets.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -47,6 +48,18 @@ const uint8_t *ResolveUnit(const char *token) {
return static_cast<const uint8_t *>(fn(token));
}
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
// return means the token was well-formed (the resolver never raised). This runs
// the same non-raising resolver and returns 0 for a token that maps to no
// rostered member — which the group-array push paths treat as "no auras".
uint64_t GuidForOutOfRange(const char *token) {
if (token == nullptr)
return 0;
return Unit::Identity::GuidForToken(token);
}
// Parses the filter string. Returns Helpful by default; Harmful if
// the string contains the substring "HARMFUL". Case-sensitive — the
// modern API documents the filter tokens as upper-case constants
@@ -91,16 +104,25 @@ const char *ArgOptString(void *L, int idx) {
int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter, bool playerOnly = false) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, nearby party range fluctuation) may still be live in
// the Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
} else {
// No live CGUnit — an out-of-range / cross-map groupmate. The server
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
// them the same way the built-in UnitBuff/UnitDebuff do.
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, playerOnly))
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, party range fluctuation) may still be live in the
// Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
Game::Lua::PushNil(L);
return 1;
}
@@ -152,13 +174,21 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const bool playerOnly = HasPlayerFilter(filterStr);
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellID(
unit, static_cast<uint32_t>(spellID), fp, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
static_cast<uint32_t>(spellID), fp,
playerOnly)) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -177,13 +207,19 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const bool playerOnly = HasPlayerFilter(filterStr);
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
spellName, fp, playerOnly)) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -234,8 +270,6 @@ int __fastcall Script_GetUnitAuras(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (unit == nullptr)
return 1;
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
@@ -246,10 +280,21 @@ int __fastcall Script_GetUnitAuras(void *L) {
const bool both = !hasHelpful && !hasHarmful;
int nextKey = 1;
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
if (unit != nullptr) {
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
} else {
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
// group-member aura array (spell IDs the server still transmits), the
// same source the built-in UnitBuff/UnitDebuff read out of range.
const uint64_t guid = GuidForOutOfRange(unitToken);
if (hasHelpful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, playerOnly, 1, nextKey);
if (hasHarmful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, playerOnly, 1, nextKey);
}
return 1;
}
+330 -42
View File
@@ -17,6 +17,7 @@
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
@@ -139,6 +140,22 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// Report "no timer" once the stored expiration has elapsed — this is not a
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
// expiration table and returns:
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
// return expiration[slot] - now; // else remaining
// Permanent auras (paladin auras, passives, racials) carry an infinite
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
// cast-time in this slot for them, so it's always "already elapsed" and the
// engine shows no countdown. The signed `(int)` compare also absorbs the
// ~24.86-day GetTickCount wrap (both operands wrap together).
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
return 0.0; // expiration reached/passed → permanent or no timer
return static_cast<double>(expirationMs) * 0.001;
}
@@ -419,46 +436,39 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// yields nil, so the table doesn't need an explicit entry.
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
// unit's auras except the local player's — for everyone else (target,
// party, raid, mouseover) `expirationTime` stays 0 unless the
// `Aura::Source` cache observed the cast. For the player we read the
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
// returns), which gives a real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
// (the *base* value); talent / glyph duration-extension modifiers are
// already baked into the expiration timestamp on the caster's side, so
// `expirationTime - GetTime()` reflects true remaining time even when the
// base `duration` doesn't include the talent boost.
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
// (stacks, level, is-this-the-player), the others pass their known-or-default
// values.
//
// Timing rules (identical across paths): `duration` is the Spell.dbc →
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID).
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications, int unitLevel,
bool isPlayer) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
if (isPlayer && spellID != 0) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
// cast time (the descriptor has neither). The player-buff table above is
// authoritative for the player's own expiration; the cache fills it for
// everyone else. Prefer the applied (caster-modified) duration so it stays
// consistent with `expirationTime` — otherwise a talented DoT (Improved
// Shadow Word: Pain) would show remaining time exceeding the base.
if (spellID != 0) {
if (spellID != 0 && guid != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
@@ -466,9 +476,16 @@ void Push(void *L, const uint8_t *unit, int slot) {
casterGuid = c;
}
}
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
expirationTime, casterGuid);
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer());
}
namespace {
@@ -479,17 +496,12 @@ namespace {
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
double duration = 0.0;
if (c.durationMs != 0) {
duration = static_cast<double>(c.durationMs) * 0.001;
} else {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
}
const double expirationTime =
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
c.casterGuid);
// `PushEnriched` re-reads the same cache entry `c` came from (by
// guid+spellID) to fill caster / expiration / applied duration, so the
// result is identical to reading `c`'s fields directly. Stacks aren't in
// SMSG_SPELL_GO, so `applications` is 1.
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
@@ -633,4 +645,280 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
}
}
// ── Out-of-range groupmate path ────────────────────────────────────────────
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
}
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return 0;
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `playerOnly` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
bool playerOnly) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return false;
if (playerOnly) {
uint64_t c = 0;
uint32_t e = 0;
uint32_t d = 0;
if (!Aura::Source::Get(guid, id, &c, &e, &d) ||
c != Unit::Identity::PlayerGuid())
return false;
}
return true;
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
// roster lookup; only hit on the out-of-range path.
int GroupMemberLevel(uint64_t guid) {
return Group::MemberStats::Lookup(guid).level;
}
// True if the group array exposes ANY visible aura (either kind). When it
// does, the server's out-of-range aura data is present and authoritative, so
// we do NOT supplement from the Aura::Source cache — mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only when the array is entirely empty do we fall back to the cache: the
// server resends out-of-range stats as deltas only, so a member seen in range
// and then moved away again leaves an empty party-stats aura array (the array
// is cleared when they become visible and never re-sent), yet their auras
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
bool playerOnly) {
if (!IsVisible(SpellRecord(c.spellId)))
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
if (GroupSpellID(arr, slot) == c.spellId)
return false;
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
return false;
return true;
}
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
int oneBasedIndex, Filter filter, bool playerOnly) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
if (++seen == oneBasedIndex) {
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
GroupMemberLevel(guid), false);
return true;
}
}
return false;
}
// Append every cache-fallback aura of `filter`'s kind into the array table.
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
Filter filter, bool playerOnly, int outerIdx,
int &nextKey) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
const int level = GroupMemberLevel(guid);
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
false);
Game::Lua::SetTable(L, outerIdx);
}
}
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, bool playerOnly) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
if (spellName != nullptr) {
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false);
return true;
}
return false;
}
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
false);
return true;
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
playerOnly);
return false;
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly) {
if (guid == 0 || spellID == 0)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
playerOnly))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
playerOnly))
return true;
}
return false;
}
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly) {
if (guid == 0 || spellName == nullptr || *spellName == '\0')
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0)
continue;
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
playerOnly))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
playerOnly))
return true;
}
return false;
}
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
int outerIdx, int &nextKey) {
if (guid == 0)
return;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, level, false);
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, playerOnly, outerIdx,
nextKey);
}
} // namespace Aura::Data
+41
View File
@@ -132,4 +132,45 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey);
// ── Out-of-range groupmate path ────────────────────────────────────────────
//
// When a party/raid member has no live CGUnit at all (different map, far out
// of range) the token resolves to a GUID but no object, so there is no
// descriptor to read. The server still transmits that member's current aura
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
// out of range — and build the same AuraData table.
//
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
// no results. These are only meaningful when the unit has NO descriptor —
// callers use them in the `unit == nullptr` branch, so there is no descriptor
// to dedup against.
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
// matching `filter`. On a hit pushes the table and returns true; otherwise
// pushes nothing and returns false (caller pushes nil).
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly = false);
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
// restricted to one filter range (nullptr = both, helpful first). Returns true
// on a hit (table pushed), false otherwise (nothing pushed).
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly = false);
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly = false);
// Appends every group-array aura on `guid` matching `filter` into the array
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
int outerIdx, int &nextKey);
} // namespace Aura::Data
+157 -2
View File
@@ -70,6 +70,49 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- Recent local-player aura casts --------------------------------------
// The aura-application hooks (OnAuraAdded / OnAuraStacksChanged) get no caster,
// so they compute the UNMODIFIED base duration (`skipMod = 1`). That's correct
// for other units' auras, but wrong for one the LOCAL PLAYER just cast: it
// should carry the player-talented duration (Improved Shadow Word: Pain → 24s
// vs the 18s base). SpellGo normally owns that entry with the talented value,
// but when it can't attribute the cast to this target — an empty SMSG_SPELL_GO
// hit list files it under the caster instead, or a slot reshuffle evicts and
// refills the entry after SpellGo ran — the base value is what's left, so the
// duration flickers 24 → 18. SpellGo (which knows caster == player) records the
// spellId here so the application hooks can recover the player-modified
// duration for it.
constexpr int kRecentCastCount = 16;
constexpr uint32_t kRecentCastTtlMs = 1500;
struct RecentCast {
uint32_t spellId;
uint32_t tMs; // 0 = empty
};
RecentCast g_recentCasts[kRecentCastCount];
int g_recentCastCursor = 0;
void RememberPlayerCast(uint32_t spellId) {
const uint32_t now = NowMs();
for (auto &r : g_recentCasts)
if (r.spellId == spellId) {
r.tMs = now;
return;
}
g_recentCasts[g_recentCastCursor] = {spellId, now};
g_recentCastCursor = (g_recentCastCursor + 1) % kRecentCastCount;
}
bool WasRecentPlayerCast(uint32_t spellId) {
if (spellId == 0)
return false;
const uint32_t now = NowMs();
for (const auto &r : g_recentCasts)
if (r.spellId == spellId && r.tMs != 0 && now - r.tMs <= kRecentCastTtlMs)
return true;
return false;
}
// ---- Cache ---------------------------------------------------------------
// Helpful/harmful classification, recorded from the aura's descriptor slot
@@ -135,6 +178,45 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
true};
}
// ---- Out-of-range group-member aura snapshots ---------------------------
//
// Transition state for `ObserveGroupAuras`: the last aura spell-ID set we saw
// for each out-of-range group member, so we can stamp a duration guess only
// when an aura genuinely appears (not for auras already present when we first
// started watching that member). See the header for the rationale.
struct GroupSnapshot {
uint64_t guid;
uint32_t touchMs;
bool used;
uint16_t ids[Offsets::UNIT_AURA_TOTAL];
};
constexpr int kGroupSnapshots = 44; // MAX_RAID (40) + slack
constexpr uint32_t kGroupSnapshotTtlMs = 30000; // forget members not polled for 30s
GroupSnapshot g_groupSnaps[kGroupSnapshots];
bool SnapshotHasId(const GroupSnapshot &s, uint16_t id) {
for (uint16_t v : s.ids)
if (v == id)
return true;
return false;
}
// Stamp a base-duration expiration guess for a group aura that just appeared.
// casterGuid stays 0 (unknown), so `Store`'s guard preserves any real SpellGo
// timing we already hold. Skips non-aura spells and auras with no finite base
// duration (nothing meaningful to guess — leave expiration unknown).
void StampGroupGuess(uint64_t guid, uint16_t spellId, int8_t kind, uint32_t now) {
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ false);
if (base == 0)
return;
Store(guid, spellId, /*casterGuid*/ 0, now + base, base, /*fromCast*/ false,
kind);
}
// Evict the entry for an aura the engine reports gone. Keyed by (target,
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
// fallback would keep surfacing a dropped aura until its computed expiry —
@@ -156,6 +238,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
// Reset transition baselines too: post-transition group auras re-sync and
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
s.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
@@ -191,6 +277,12 @@ void OnWorldTick() {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
}
// Forget snapshots for members we haven't polled recently (left the group,
// or no longer displayed) so a later re-appearance re-baselines cleanly.
for (auto &s : g_groupSnaps) {
if (s.used && now - s.touchMs > kGroupSnapshotTtlMs)
s.used = false;
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
@@ -252,6 +344,12 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
// Let the application hooks recover the player-talented duration for this
// spell if they end up owning the target's entry (empty hit list / refill
// race) — see WasRecentPlayerCast.
if (casterIsPlayer)
RememberPlayerCast(spellId);
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true,
@@ -283,9 +381,15 @@ void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
if (unitGuid == 0)
return;
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
// If the local player just cast this aura, use its player-talented duration
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
Store(unitGuid, spellId, caster, expirationMs, durationMs,
/*fromCast*/ false, kind);
}
@@ -423,4 +527,55 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
return n;
}
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
if (guid == 0 || auraArray == nullptr)
return;
const int total = Offsets::UNIT_AURA_TOTAL;
const int buffCount = Offsets::UNIT_AURA_BUFF_COUNT;
const uint32_t now = NowMs();
GroupSnapshot *snap = nullptr;
for (auto &s : g_groupSnaps)
if (s.used && s.guid == guid) {
snap = &s;
break;
}
if (snap == nullptr) {
// First sight of this member — record a baseline without stamping;
// their current auras have unknown age.
GroupSnapshot *dst = nullptr;
for (auto &s : g_groupSnaps)
if (!s.used) {
dst = &s;
break;
}
if (dst == nullptr) { // full — evict the least-recently-polled
dst = &g_groupSnaps[0];
for (auto &s : g_groupSnaps)
if (s.touchMs < dst->touchMs)
dst = &s;
}
dst->guid = guid;
dst->used = true;
dst->touchMs = now;
for (int i = 0; i < total; ++i)
dst->ids[i] = auraArray[i];
return;
}
// Stamp a guess for every spell ID present now but absent from the prior
// snapshot (a genuine appearance), then refresh the snapshot.
for (int slot = 0; slot < total; ++slot) {
const uint16_t id = auraArray[slot];
if (id == 0 || SnapshotHasId(*snap, id))
continue;
StampGroupGuess(guid, id, slot < buffCount ? KIND_HELPFUL : KIND_HARMFUL,
now);
}
for (int i = 0; i < total; ++i)
snap->ids[i] = auraArray[i];
snap->touchMs = now;
}
} // namespace Aura::Source
+15
View File
@@ -74,4 +74,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// Feeds the cache from an out-of-range group member's aura spell-ID array
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
// carries the spell IDs and the server sends it promptly when an aura is
// added/removed. So a spell ID that newly *appears* here did so ~one server
// tick + latency after the real application: we stamp a best-effort
// `now + base duration` expiration (base only — no remote caster mods, no
// caster recorded). Auras already present the first time we see a member have
// unknown age and are NOT stamped; only genuine absent->present transitions
// are. Idempotent within a frame; call it before enumerating the array so a
// just-appeared aura carries its guess on the same poll. The Store guard keeps
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
} // namespace Aura::Source
+83
View File
@@ -0,0 +1,83 @@
// 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/>.
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
// exist whenever the DLL is injected, even if the addon is disabled. The
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
// Lua-side in the addon, mirroring retail's Mixin.lua.
//
// Behaviour matches the canonical Lua implementation exactly:
// Mixin(object, ...) -- shallow-copy every key/value from each table
// argument onto `object`; returns `object`.
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
// survive for the next Next() iteration.
Game::Lua::PushValue(L, -2); // key copy
Game::Lua::PushValue(L, -2); // value copy
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
}
}
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
return 0; // unreachable
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
int __fastcall Script_CreateFromMixins(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // new object at index top + 1
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
}
// Pure table helpers — register on both the in-game and glue states so they're
// always present (matching the "always available" intent).
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::Mixin
+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);
+170
View File
@@ -0,0 +1,170 @@
// 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/>.
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
// Windows clipboard and return the number of bytes copied. Vanilla has no
// clipboard access at all (Blizzard never exposed one), so this is a pure
// ClassicAPI addition backed by the Win32 clipboard API.
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
//
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include <string>
#include <windows.h>
namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
++i;
break; // the |t is dropped by the 't' case on the next iteration
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
return out;
}
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
// success. Does not free the allocation on success — ownership passes to the
// clipboard.
bool SetClipboardUtf8(const char *data, int byteLen) {
if (!OpenClipboard(nullptr))
return false;
bool ok = false;
if (EmptyClipboard()) {
// Empty string still writes a valid (empty) clipboard entry.
const int wlen =
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
if (h != nullptr) {
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
if (dst != nullptr) {
if (wlen > 0)
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
ok = true; // clipboard owns h now
}
if (!ok)
GlobalFree(h);
}
}
CloseClipboard();
return ok;
}
int __fastcall Script_CopyToClipboard(void *L) {
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
return 0; // unreachable
}
const char *text = Game::Lua::ToString(L, 1);
const unsigned len = Game::Lua::StrLen(L, 1);
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
int copiedLen;
if (removeMarkup) {
const std::string stripped = StripMarkup(text, len);
copiedLen = static_cast<int>(stripped.size());
SetClipboardUtf8(stripped.data(), copiedLen);
} else {
copiedLen = static_cast<int>(len);
SetClipboardUtf8(text, copiedLen);
}
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
}
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Clipboard::Copy
+292
View File
@@ -0,0 +1,292 @@
// 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_ColorUtil` — the modern color-space + text-color-code helpers, backported
// to 1.12. Every one is pure math / string formatting (no engine state), so
// this is a plain reimplementation rather than a hook.
//
// Conventions verified in-game against a retail-family client that ships
// C_ColorUtil natively:
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
// no alpha channel).
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
//
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
// optional a field is honored (default 1.0) though retail's colorRGB type
// omits it — for the documented no-alpha case both give ff.
#include "Game.h"
#include <cmath>
#include <cstdio>
#include <string>
namespace Color::Util {
namespace {
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
int ToByte(double v) {
const double c = Clamp01(v);
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
}
double Max3(double a, double b, double c) {
double m = a > b ? a : b;
return m > c ? m : c;
}
double Min3(double a, double b, double c) {
double m = a < b ? a : b;
return m < c ? m : c;
}
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
// --- Pure conversions --------------------------------------------------
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
v = mx;
s = (mx <= 0.0) ? 0.0 : d / mx;
if (d <= 0.0) { // achromatic
h = -1.0;
return;
}
if (mx == r)
h = 60.0 * std::fmod((g - b) / d, 6.0);
else if (mx == g)
h = 60.0 * ((b - r) / d + 2.0);
else
h = 60.0 * ((r - g) / d + 4.0);
if (h < 0.0)
h += 360.0;
}
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
r = g = b = v;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double hh = h / 60.0;
const int i = static_cast<int>(std::floor(hh));
const double f = hh - i;
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * f);
const double t = v * (1.0 - s * (1.0 - f));
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
ho = h; // hue (incl. -1) passes through unchanged
l = v * (1.0 - sv / 2.0);
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
}
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
ho = h;
v = l + sl * MinLuma(l);
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
}
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic
r = g = b = l;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
const double hh = h / 60.0;
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
const double m = l - c / 2.0;
double r1, g1, b1;
switch (static_cast<int>(std::floor(hh)) % 6) {
case 0: r1 = c; g1 = x; b1 = 0.0; break;
case 1: r1 = x; g1 = c; b1 = 0.0; break;
case 2: r1 = 0.0; g1 = c; b1 = x; break;
case 3: r1 = 0.0; g1 = x; b1 = c; break;
case 4: r1 = x; g1 = 0.0; b1 = c; break;
default: r1 = c; g1 = 0.0; b1 = x; break;
}
r = r1 + m;
g = g1 + m;
b = b1 + m;
}
// --- Lua glue ----------------------------------------------------------
// Read arg `n` as a number, or raise the usage error.
double Arg(void *L, int n, const char *usage) {
if (!Game::Lua::IsNumber(L, n)) {
Game::Lua::Error(L, "%s", usage);
return 0.0; // unreachable
}
return Game::Lua::ToNumber(L, n);
}
// Read a numeric field from the color table at (absolute, positive) index
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
double ColorField(void *L, int idx, const char *key, double dflt) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, idx);
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
Game::Lua::SetTop(L, -2); // pop the value
return v;
}
// "AARRGGBB" from a color table at `idx`, alpha default ff.
std::string HexFromColor(void *L, int idx) {
const int a = ToByte(ColorField(L, idx, "a", 1.0));
const int r = ToByte(ColorField(L, idx, "r", 0.0));
const int g = ToByte(ColorField(L, idx, "g", 0.0));
const int b = ToByte(ColorField(L, idx, "b", 0.0));
char buf[9];
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
return std::string(buf, 8);
}
int __fastcall Script_ConvertRGBToHSV(void *L) {
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
double h, s, v;
RgbToHsv(r, g, b, h, s, v);
Game::Lua::PushNumber(L, h);
Game::Lua::PushNumber(L, s);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSVToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
double r, g, b;
HsvToRgb(h, s, v, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_ConvertHSVToHSL(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
double ho, sl, l;
HsvToHsl(h, s, v, ho, sl, l);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sl);
Game::Lua::PushNumber(L, l);
return 3;
}
int __fastcall Script_ConvertHSLToHSV(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
double ho, sv, v;
HslToHsv(h, s, l, ho, sv, v);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sv);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSLToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
double r, g, b;
HslToRgb(h, s, l, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_GenerateTextColorCode(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
return 0; // unreachable
}
const std::string hex = HexFromColor(L, 1);
Game::Lua::PushString(L, hex.c_str());
return 1;
}
int __fastcall Script_WrapTextInColor(void *L) {
const char *text = Game::Lua::ToString(L, 1);
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
return 0; // unreachable
}
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
int __fastcall Script_WrapTextInColorCode(void *L) {
const char *text = Game::Lua::ToString(L, 1);
const char *code = Game::Lua::ToString(L, 2);
if (text == nullptr || code == nullptr) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
return 0; // unreachable
}
std::string out = std::string("|c") + code + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
&Script_ConvertRGBToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
&Script_ConvertHSVToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
&Script_ConvertHSVToHSL);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
&Script_ConvertHSLToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
&Script_ConvertHSLToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
&Script_GenerateTextColorCode);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
&Script_WrapTextInColor);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
&Script_WrapTextInColorCode);
}
// Pure helpers — useful on both the in-game and glue states.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Color::Util
+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
+112
View File
@@ -0,0 +1,112 @@
// 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/>.
#include "group/MemberStats.h"
#include "Offsets.h"
namespace Group::MemberStats {
namespace {
// Both roster lookups are `__fastcall(u64 *guid) -> block*` (NULL on miss).
using Lookup_t = const uint8_t *(__fastcall *)(uint64_t *guid);
uint16_t U16(const uint8_t *p, int off) {
return *reinterpret_cast<const uint16_t *>(p + off);
}
} // namespace
Stats Lookup(uint64_t guid) {
Stats s;
if (guid == 0)
return s;
// Party stats block — the full record.
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
s.valid = true;
s.full = true;
s.powerType = e[Offsets::OFF_GROUP_MEMBER_STATS_POWER_TYPE];
s.health = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_POWER);
s.maxPower = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_POWER);
s.level = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_LEVEL);
s.areaId = U16(e, Offsets::OFF_GROUP_MEMBER_AREA_ID);
s.online = (e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
// The party stats block carries no dead / subgroup / rank fields; those
// are raid-only (dead comes from UnitIsDead on the live unit otherwise).
return s;
}
// Raid slot — same struct GetRaidRosterInfo reads.
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
s.valid = true;
s.full = false;
s.powerType = e[Offsets::OFF_RAID_SLOT_POWER_TYPE];
s.health = U16(e, Offsets::OFF_RAID_SLOT_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_RAID_SLOT_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_RAID_SLOT_POWER);
s.maxPower = U16(e, Offsets::OFF_RAID_SLOT_MAX_POWER);
s.level = U16(e, Offsets::OFF_RAID_SLOT_LEVEL);
s.areaId = U16(e, Offsets::OFF_RAID_SLOT_AREA_ID);
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_FLAGS);
s.online = (flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0;
s.dead = (flags & Offsets::RAID_SLOT_FLAG_DEAD) != 0;
s.subgroup = static_cast<int>(*reinterpret_cast<const uint32_t *>(
e + Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
s.rank = static_cast<int>(
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_RANK));
return s;
}
return s;
}
const uint16_t *AuraArray(uint64_t guid) {
if (guid == 0)
return nullptr;
// Party stats block first — auras at +0x1a, but only for an online member
// (the engine's Script_UnitBuff bails on the party path when the online bit
// is clear).
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
if ((e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) == 0)
return nullptr;
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
}
// Raid slot — auras at +0x64, no online gate (mirrors the engine's raid
// branch, which reads the array unconditionally).
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_RAID_SLOT_AURAS);
}
return nullptr;
}
} // namespace Group::MemberStats
+67
View File
@@ -0,0 +1,67 @@
// 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/>.
#pragma once
#include <cstdint>
namespace Group::MemberStats {
// Cached stats for a party/raid member, read from the group roster
// (fed by SMSG_PARTY_MEMBER_STATS). This is the ONLY source for a member with
// no live CGUnit — a groupmate on a different map or otherwise out of range —
// and is exactly what the engine's UnitHealth / UnitMana fall back to. Any
// module that reads unit fields off the descriptor should consult this when
// the descriptor is unavailable so it doesn't report 0 for out-of-range
// groupmates.
//
// `full` distinguishes the two backing structures: the party stats block and
// the raid slot. Both carry health / power / level / area / online; `dead`,
// `subgroup`, and `rank` come only from the raid slot (0 / false on the party
// path). Power values are RAW (divide by the power divisor for display);
// health is the roster u16, matching what UnitHealth returns out of range.
struct Stats {
bool valid = false;
bool full = false; // true = party stats block; false = raid slot
int powerType = 0;
uint32_t health = 0;
uint32_t maxHealth = 0;
uint32_t power = 0; // raw (pre-divisor)
uint32_t maxPower = 0; // raw (pre-divisor)
int level = 0;
int areaId = 0; // AreaTable.dbc ID
bool online = false;
bool dead = false; // raid slot only
int subgroup = 0; // raid slot only, 1-based (0 = party path)
int rank = 0; // raid slot only
};
// Look up a member by GUID. Tries the party stats block, then the raid slot,
// mirroring the engine's UnitHealth/UnitMana out-of-range fallback. Returns an
// invalid `Stats` (`valid == false`) when the GUID isn't a rostered member.
// Pure — no Lua, no throw; safe from any context.
Stats Lookup(uint64_t guid);
// Pointer to an out-of-range groupmate's 48-slot aura spell-ID array (buffs
// 0..31, debuffs 32..47), or nullptr if `guid` isn't a rostered member — or is
// an *offline* party member (the engine surfaces no auras for those). Mirrors
// the engine's own out-of-range fallback in Script_UnitBuff/Script_UnitDebuff
// exactly: the party stats block at +OFF_GROUP_MEMBER_STATS_AURAS gated on the
// online flag, else the raid slot at +OFF_RAID_SLOT_AURAS. Spell IDs only —
// SMSG_PARTY_MEMBER_STATS carries no caster/duration/stacks, and truncates IDs
// to u16 (custom IDs > 65535 are wrong; same as the built-in UnitBuff). The
// pointer is into live engine memory — read it immediately, don't cache it.
// Pure — no Lua, no throw; safe from any context.
const uint16_t *AuraArray(uint64_t guid);
} // namespace Group::MemberStats
+101 -24
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,23 @@ 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;
// 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 +143,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) {
@@ -257,12 +323,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 +350,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 +379,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();
+1 -12
View File
@@ -92,18 +92,7 @@ int MapRollType(int packetType) {
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
// gates on this same lookup succeeding. Returns nullptr if not cached.
const uint8_t *LookupPlayerRecord(uint64_t guid) {
if (guid == 0)
return nullptr;
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
uint32_t hi, void *cookie,
void *cb, void *ud, char flag);
auto fn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
uint32_t cookie[2] = {0, 0};
return fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
cookie, nullptr, nullptr, 0);
return Unit::Identity::PlayerInfoRecord(guid);
}
const char *NameFromRecord(const uint8_t *rec) {
+5 -22
View File
@@ -39,6 +39,7 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -47,20 +48,8 @@ namespace Player::Info {
namespace {
// `FUN_0055F080` — `__thiscall(this=cache, guidLo, guidHi, &cookie,
// callback, userData, retryFlag) → entryData* or NULL`.
using LookupOrFetch_t = const uint8_t *(__thiscall *)(
void *cache,
uint32_t guidLo,
uint32_t guidHi,
uint64_t *cookie,
void *callback,
void *userData,
int retryFlag);
// Thin shim: parses a GUID string and splits to hi/lo dwords for the
// engine's `__thiscall` cache lookup, which takes them as separate
// args. Shared parser lives in `Guid::Parse`.
// Thin shim: parses a GUID string and splits to hi/lo dwords. Shared parser
// lives in `Guid::Parse`.
bool ParseGUID(const char *str, uint32_t &outHi, uint32_t &outLo) {
uint64_t value = 0;
if (!Guid::Parse(str, &value))
@@ -82,14 +71,8 @@ int __fastcall Script_GetPlayerInfoByGUID(void *L) {
if (hi == 0 && lo == 0)
return 0;
auto fn = reinterpret_cast<LookupOrFetch_t>(Offsets::FUN_PLAYER_INFO_LOOKUP);
auto *cache = reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE));
uint64_t cookie = 0;
const uint8_t *entry = fn(cache, lo, hi, &cookie,
nullptr /*callback*/, nullptr /*userData*/,
0 /*retryFlag*/);
const uint8_t *entry = Unit::Identity::PlayerInfoRecord(
(static_cast<uint64_t>(hi) << 32) | lo);
const char *name = nullptr;
const char *realm = nullptr;
+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;
+76
View File
@@ -0,0 +1,76 @@
// 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_Spell.GetSpellEffectMechanics(spellID)` -> { m1, m2, m3 }
//
// Returns a spell's three per-effect mechanic ids (Spell.dbc
// `EffectMechanic[3]`) as a 1-based array table, or nil for an invalid /
// out-of-range spell ID. Each entry is a standard WoW SpellMechanic id
// (0 = that effect carries no mechanic); the numbering matches
// C_Spell.GetSpellMechanicByID (1 = Charm, 7 = Root, 12 = Stun,
// 15 = Bleed, 17 = Polymorph, ...).
//
// Complements GetSpellMechanicByID, which reads only the SPELL-level
// `Mechanic` field (+0x14). Vanilla frequently stores the meaningful
// mechanic on an *effect* rather than the spell — periodic damage such
// as bleeds is the common case — so effect-mechanic-aware callers (e.g.
// bleed classification for immunity tracking) need this array too:
//
// local em = C_Spell.GetSpellEffectMechanics(spellID)
// if em then for i = 1, 3 do if em[i] == 15 then --[[ bleed ]] end end end
//
// Reads Spell.dbc directly, so it covers every spell the client knows —
// not just the player's spellbook — with no caching or network round-trip
// (Spell.dbc is resident from boot).
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Spell::EffectMechanic {
namespace {
int __fastcall Script_GetSpellEffectMechanics(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil for invalid / out-of-range spell IDs
auto *mechanics = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_MECHANIC);
// Fixed 1-based array of all three effects (0 = effect has no mechanic),
// matching the raw EffectMechanic[3] layout so callers can index by slot.
Game::Lua::NewTable(L);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // key
Game::Lua::PushNumber(L, static_cast<double>(mechanics[i])); // value
Game::Lua::SetTable(L, -3);
}
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellEffectMechanics",
&Script_GetSpellEffectMechanics);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::EffectMechanic
+33 -20
View File
@@ -443,34 +443,46 @@ static int __fastcall Script_C_IsSpellPassive(void *L) {
// arrays and not in the talent tree).
// - Same shape modern WoW (5.4.8+) uses for the same function — see
// `[0x011C25D8]` in 5.4.8's `Wow.exe`.
// Shared read of the engine's known-spell bitmap (the same bit the helper
// at `0x0060C740` consults): true iff the local player knows `spellID`.
// Bounds-checked; false for out-of-range IDs or before the bitmap exists.
static bool PlayerKnowsSpell(int spellID) {
if (spellID < 1)
return false;
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t mask = 1u << (spellID & 31);
return (bitmap[spellID >> 5] & mask) != 0;
}
static int __fastcall Script_IsPlayerSpell(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: IsPlayerSpell(spellID)");
return 0;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID < 1) {
Game::Lua::PushBool(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, PlayerKnowsSpell(spellID));
return 1;
}
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
// `CanDualWield()` — true if the local player can equip a weapon in the off
// hand. The server tracks this as a plain bool (`Player::m_canDualWield`)
// flipped on only by `SPELL_EFFECT_DUAL_WIELD` (effect 40); no class has it
// innately. Verified against the client Spell.dbc: exactly ONE spell carries
// that effect — 674 "Dual Wield", the trained warrior/rogue/hunter passive —
// and it lands in the known-spell bitmap like any learned spell. So there's
// no separate client-visible capability flag to read; `IsPlayerSpell(674)`
// is the exact equivalent of the server's `CanDualWield()`.
static constexpr int kDualWieldSpellID = 674;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t mask = 1u << (spellID & 31);
const bool isKnown = (bitmap[spellID >> 5] & mask) != 0;
Game::Lua::PushBoolean(L, isKnown);
static int __fastcall Script_CanDualWield(void *L) {
Game::Lua::PushBoolean(L, PlayerKnowsSpell(kDualWieldSpellID));
return 1;
}
@@ -623,6 +635,7 @@ static void RegisterLuaFunctions() {
&Script_FindSpellBookSlotByID);
Game::Lua::RegisterGlobalFunction("IsPassiveSpell", &Script_IsPassiveSpell);
Game::Lua::RegisterGlobalFunction("IsPlayerSpell", &Script_IsPlayerSpell);
Game::Lua::RegisterGlobalFunction("CanDualWield", &Script_CanDualWield);
Game::Lua::RegisterGlobalFunction("IsSpellKnown", &Script_IsSpellKnown);
Game::Lua::RegisterGlobalFunction("IsHarmfulSpell", &Script_IsHarmfulSpell);
Game::Lua::RegisterGlobalFunction("IsHelpfulSpell", &Script_IsHelpfulSpell);
+77
View File
@@ -0,0 +1,77 @@
// 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/>.
#include "Game.h"
#include <cmath>
#include <vector>
namespace Table::Count {
// `numTableNodes, numArrayNodes, maxArrayIndex = table.count(tbl)` — a modern
// WoW diagnostic (added retail 11.2.5) reporting how a table is populated.
// These are COUNTS of live entries, not the table's allocated capacity:
//
// numTableNodes total number of key/value pairs.
// numArrayNodes count of pairs whose key is an integer in [1..numTableNodes]
// (the "looks like a contiguous array part" heuristic).
// maxArrayIndex the largest positive integral key (>= 1), or 0 if none.
// Verified against retail: negative/zero integral keys do
// NOT count ({[-3]=x} -> 1, 0, 0).
//
// Pure iteration — no Lua-internal struct layout needed. Note we only ever
// read a key via ToNumber, never ToString: lua_tostring on a NUMERIC key
// rewrites it to a string in place and corrupts lua_next's traversal.
static int __fastcall Script_table_count(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: table.count(table)");
return 0;
}
std::vector<double> integralKeys;
int total = 0;
double maxKey = 0.0; // largest positive integral key seen; 0 = none
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, 1) != 0) {
++total;
// key at -2, value at -1.
if (Game::Lua::Type(L, -2) == Game::Lua::TYPE_NUMBER) {
const double k = Game::Lua::ToNumber(L, -2);
if (k == std::floor(k)) { // integral key
integralKeys.push_back(k);
if (k >= 1.0 && k > maxKey) // only positive keys count
maxKey = k;
}
}
Game::Lua::SetTop(L, -2); // pop value, keep key for the next Next()
}
int numArray = 0;
for (double k : integralKeys)
if (k >= 1.0 && k <= static_cast<double>(total))
++numArray;
Game::Lua::PushNumber(L, static_cast<double>(total));
Game::Lua::PushNumber(L, static_cast<double>(numArray));
Game::Lua::PushNumber(L, maxKey);
return 3;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("table", "count", &Script_table_count);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Table::Count
+137 -11
View File
@@ -18,7 +18,12 @@
namespace Talent::Info {
// `GetTalentSpellID(tabIndex, talentIndex[, rank])` — returns the
// Cross-class talent record resolver — defined below (before
// `GetTalentIDByIndex`), shared by both talent getters' optional classID arg.
static const uint8_t *ResolveTalentRecordForClass(int classID, int tabIndex,
int talentIndex);
// `GetTalentSpellID(tabIndex, talentIndex[, rank[, classID]])` — returns the
// spellID for the given talent at the requested rank, or `nil` if the
// talent isn't allocated (or any arg is out of range).
//
@@ -34,13 +39,22 @@ namespace Talent::Info {
// points in this talent), falls back to rank 1 so the function still
// produces the talent's canonical spellID for unallocated talents.
//
// `classID` is optional (4th arg; pass `rank` as nil to use the default).
// When a number, resolves against `Talent.dbc` / `TalentTab.dbc` for that
// class instead of the player's runtime tree — same cross-class path as
// `GetTalentIDByIndex`. There's no "currentRank" for a class you aren't, so a
// cross-class query with no explicit `rank` defaults to rank 1 (the talent's
// canonical spellID). Ordering matches the player-class path exactly (see
// `ResolveTalentRecordForClass`).
//
// Returns `nil` when:
// - tab/talentIndex/rank are non-numeric or out of range
// - tab/talentIndex/rank/classID are non-numeric-when-required or out of range
// - the explicit `rank` exceeds the talent's allocated max (e.g. rank
// 5 on a 1-rank talent — the SpellRank slot is zero)
static int __fastcall Script_GetTalentSpellID(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GetTalentSpellID(tabIndex, talentIndex[, rank])");
Game::Lua::Error(L,
"Usage: GetTalentSpellID(tabIndex, talentIndex[, rank[, classID]])");
return 0;
}
const int tabIndex = static_cast<int>(Game::Lua::ToNumber(L, 1));
@@ -48,6 +62,29 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
if (tabIndex < 1 || talentIndex < 1)
return 0;
// Explicit classID → cross-class DBC path. `rank` (arg 3) is still honored
// when given; without it we can't ask the engine for a currentRank on a
// class the player isn't, so default to rank 1.
if (Game::Lua::IsNumber(L, 4)) {
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 4));
int rank = 1;
if (Game::Lua::IsNumber(L, 3)) {
rank = static_cast<int>(Game::Lua::ToNumber(L, 3));
if (rank < 1 || rank > Offsets::TALENT_MAX_RANKS)
return 0;
}
const uint8_t *rec =
ResolveTalentRecordForClass(classID, tabIndex, talentIndex);
if (rec == nullptr)
return 0;
const uint32_t spellID = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_TALENT_SPELL_RANK + (rank - 1) * sizeof(uint32_t));
if (spellID == 0)
return 0; // rank slot not populated (rank > maxRank for this talent)
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
// Pre-validate tab/idx so we don't trigger `lua_error` from the
// engine's `Script_GetTalentInfo` on out-of-range input. The engine
// bails to its error path (lua_error) on bad tab, bad talent index,
@@ -109,8 +146,74 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
return 1;
}
// `GetTalentIDByIndex(tabIndex, talentIndex)` — returns the Talent.dbc
// primary key (the row's ID at `TalentEntry+0x00`) for the talent at
// Cross-class talent resolution via the DBC flat arrays — mirrors the engine's
// tree builder `FUN_004f2c00` but filtered to an arbitrary class instead of the
// local player's. Used by `GetTalentSpellID` / `GetTalentIDByIndex`'s optional
// `classID` arg so an addon can inspect any class's tree, not just the one it's
// running on.
// TalentTab.dbc rowID of the `tabIndex`-th (1-based) tab belonging to `classID`,
// or 0. Tabs are taken in DBC row order and filtered by `classMask` (mask 0 =
// all classes). Race isn't part of "which class owns a tab" — vanilla tabs are
// never race-restricted — so we apply only the class half of `FUN_004f2e50`.
static uint32_t NthClassTalentTabID(int classID, int tabIndex) {
const uint32_t classBit = 1u << ((classID - 1) & 0x1F);
auto *base = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_TALENTTAB_DBC_FLAT_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_TALENTTAB_DBC_FLAT_COUNT));
if (base == nullptr)
return 0;
int match = 0;
for (int i = 0; i < count; ++i) {
const uint8_t *rec = base + i * Offsets::TALENTTAB_ENTRY_STRIDE;
const uint32_t classMask = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_TALENTTAB_CLASS_MASK);
if (classMask != 0 && (classMask & classBit) == 0)
continue;
if (++match == tabIndex)
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_TALENTTAB_ID);
}
return 0;
}
// Talent.dbc record for the `talentIndex`-th (1-based) talent whose TabID is
// `tabID`, in DBC row order — the same order the builder assigns within a tab.
// Returns null if there aren't that many.
static const uint8_t *NthTabTalentRecord(uint32_t tabID, int talentIndex) {
auto *base = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_FLAT_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_FLAT_COUNT));
if (base == nullptr)
return nullptr;
int match = 0;
for (int i = 0; i < count; ++i) {
const uint8_t *rec = base + i * Offsets::TALENT_ENTRY_STRIDE;
if (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_TALENT_DBC_TAB_ID) != tabID)
continue;
if (++match == talentIndex)
return rec;
}
return nullptr;
}
// The Talent.dbc record for (classID, tabIndex, talentIndex), or null on any
// out-of-range input. `classID` is 1-based (1..31; vanilla uses 1..11 with 6/10
// unused).
static const uint8_t *ResolveTalentRecordForClass(int classID, int tabIndex,
int talentIndex) {
if (classID < 1 || classID > 31 || tabIndex < 1 || talentIndex < 1)
return nullptr;
const uint32_t tabID = NthClassTalentTabID(classID, tabIndex);
if (tabID == 0)
return nullptr;
return NthTabTalentRecord(tabID, talentIndex);
}
// `GetTalentIDByIndex(tabIndex, talentIndex[, classID])` — returns the
// Talent.dbc primary key (the row's ID at `TalentEntry+0x00`) for the talent at
// the given (tab, idx). 1.12's `GetTalentInfo` returns
// `(name, icon, tier, column, currentRank, maxRank, ...)` but NOT the
// talentID, so addons that want a stable identifier (for SavedVariables
@@ -118,14 +221,22 @@ static int __fastcall Script_GetTalentSpellID(void *L) {
// encode `(class, tab, tier, column)` and hope the talent tree doesn't
// shift.
//
// Reads from the same per-tab talent arrays at `[VAR_TALENT_TAB_INFO_ARRAY]`
// that #36 `GetTalentSpellID` walks. talentID at `+0x00` of the
// `TalentEntry` was verified empirically during the GetTalentSpellID
// debug pass: `entryFirstDword=174` matched Inner Focus's Talent.dbc
// row ID (and `166` for the first Discipline talent).
// Without `classID` (or when it's nil) it reads the player's own tree from the
// per-tab talent arrays at `[VAR_TALENT_TAB_INFO_ARRAY]` (same source #36
// `GetTalentSpellID` walks). talentID at `+0x00` of the `TalentEntry` was
// verified empirically during the GetTalentSpellID debug pass:
// `entryFirstDword=174` matched Inner Focus's Talent.dbc row ID (and `166` for
// the first Discipline talent).
//
// With an explicit `classID` it resolves against the Talent.dbc / TalentTab.dbc
// flat arrays instead, so it works for any class — not just the one the addon
// is running on. The (tab, idx) ordering matches the player-class path exactly:
// both come from the engine builder's DBC-row-order walk (see
// `ResolveTalentRecordForClass` and the flat-array note in Offsets.h).
static int __fastcall Script_GetTalentIDByIndex(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GetTalentIDByIndex(tabIndex, talentIndex)");
Game::Lua::Error(L,
"Usage: GetTalentIDByIndex(tabIndex, talentIndex[, classID])");
return 0;
}
const int tabIndex = static_cast<int>(Game::Lua::ToNumber(L, 1));
@@ -133,6 +244,21 @@ static int __fastcall Script_GetTalentIDByIndex(void *L) {
if (tabIndex < 1 || talentIndex < 1)
return 0;
// Explicit classID → cross-class DBC path. Omitted/nil → player's own class
// via the engine's runtime tab-info arrays (below).
if (Game::Lua::IsNumber(L, 3)) {
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 3));
const uint8_t *rec =
ResolveTalentRecordForClass(classID, tabIndex, talentIndex);
if (rec == nullptr)
return 0;
const uint32_t talentID = *reinterpret_cast<const uint32_t *>(rec);
if (talentID == 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(talentID));
return 1;
}
const int tabCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_TALENT_TAB_COUNT));
if (tabIndex > tabCount)
+2 -2
View File
@@ -62,7 +62,7 @@
#include "item/Location.h"
#include "item/StatAccum.h"
#include "item/TooltipItem.h"
#include "tooltip/SetItemEvent.h"
#include "tooltip/SetEvents.h"
#include "ui/ColorData.h"
#include <cstdint>
@@ -598,7 +598,7 @@ int __fastcall Script_SetHyperlinkCompareItem(void *L) {
// this internal build so a handler doesn't run mid-way through the
// line-shift below (it fires for the *hovered* item's real Set* instead).
{
Tooltip::SetItemEvent::Suppressor noEvent;
Tooltip::SetEvents::Suppressor noEvent;
reinterpret_cast<BuildItemTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_ITEM)(
self, static_cast<uint32_t>(equippedID), &equippedGUID, &equippedGUID, 0, 0, 0, 0, 0, 0);
}
+241
View File
@@ -0,0 +1,241 @@
// 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/>.
// `GameTooltip:HookScript("OnTooltipSet{Item,Spell,Unit,GameObject}", fn)` —
// backport of the modern tooltip scripts that fire whenever a tooltip's item /
// spell / unit / gameobject is set, so addons can annotate tooltips by hooking
// one script instead of wrapping every Set* method. Fully native — real frame
// scripts, settable via the standard SetScript / GetScript / HookScript.
//
// 1.12 already has the machinery for tooltip scripts, just not these ones:
//
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
// all go through it. We co-hook it: for our OnTooltipSet* names (which the
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
// resolver's only contract is "return the address the script lives at", so
// external storage works exactly like an in-object slot.
//
// - Each object type has a single builder funnel that every Set*/mouseover
// path converges on (item FUN_GAMETOOLTIP_BUILD_ITEM, spell _BUILD_SPELL,
// unit _BUILD_UNIT, gameobject _BUILD_GAMEOBJECT). We co-hook each and,
// after the build, fire the matching handler via FUN_FRAME_INVOKE_SCRIPT
// (the same no-arg runner the clear uses for OnTooltipCleared) with the
// tooltip as `self`.
//
// Suppressed during our own Tooltip::Compare equipped-item build (see
// Suppressor), and guarded against a handler that re-enters a builder.
#include "SetEvents.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::SetEvents {
namespace {
// One entry per script kind. Order is arbitrary but the cell slot array and
// the builder hooks index by it.
enum ScriptKind { SK_ITEM = 0, SK_SPELL, SK_UNIT, SK_GAMEOBJECT, SK_COUNT };
// Script names, matched case-insensitively against the resolver's input.
constexpr const char *kNamesLower[SK_COUNT] = {
"ontooltipsetitem",
"ontooltipsetspell",
"ontooltipsetunit",
"ontooltipsetgameobject",
};
// Per-tooltip storage: one 8-byte {handler, context} slot per script kind.
// Tooltips are few and permanent (GameTooltip, ItemRefTooltip,
// ShoppingTooltip1/2, AtlasLootTooltip, WorldMapTooltip, plus the odd addon
// tooltip), so a small fixed table with a linear scan is plenty and gives each
// cell a stable address to hand out.
struct Cell {
void *tooltip;
uint32_t slot[SK_COUNT][2]; // [kind] = {handler, exec context}
};
constexpr int kMaxTooltips = 32;
Cell g_cells[kMaxTooltips];
int g_cellCount = 0;
uint32_t *SlotFor(void *tooltip, int kind, bool create) {
for (int i = 0; i < g_cellCount; ++i)
if (g_cells[i].tooltip == tooltip)
return g_cells[i].slot[kind];
if (!create || g_cellCount >= kMaxTooltips)
return nullptr;
Cell &c = g_cells[g_cellCount++];
c.tooltip = tooltip;
for (auto &s : c.slot) {
s[0] = 0;
s[1] = 0;
}
return c.slot[kind];
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
// --- Suppression / recursion state -------------------------------------
int g_suppress = 0; // > 0 while our own compare build runs
int g_firing = 0; // recursion guard around any handler fire
// The engine's real script invoker: binds the global `this` = frame and runs
// the handler under its own protected lua_pcall. We call it directly rather
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
// Fire the handler (if any) for `kind` on `self`.
//
// We fire from inside a builder, which is itself mid-execution of the engine's
// Script_Set* (and, in the wild, nested under addon Set* wrappers + several
// other DLLs' hooks). The invoker pushes onto the Lua stack and does NOT
// restore the top to where the outer, still-running C code left it — harmless
// from the engine's top-level dispatch, but from here it shifts the stack and
// the outer code (and other DLLs' hooks) then index garbage → deref → crash.
// Snapshot and restore the stack top around the fire so the outer code sees
// exactly the stack it left.
void FireScript(void *self, int kind) {
if (g_suppress > 0 || g_firing > 0)
return;
uint32_t *slot = SlotFor(self, kind, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
++g_firing;
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
--g_firing;
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
return engineSlot;
for (int k = 0; k < SK_COUNT; ++k)
if (EqualsIgnoreCase(name, kNamesLower[k]))
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
return 0;
}
// --- Builder co-hooks --------------------------------------------------
// Each builder is a __thiscall funnel modelled as __fastcall(self /*ecx*/,
// edx /*unused reg placeholder*/, ...real stack args...). The declared stack
// arg count MUST match the callee's RET N or the caller's stack is corrupted.
// Builders whose callers use the return value (spell, unit) are declared to
// return it so the co-hook forwards it.
// Item — FUN_GAMETOOLTIP_BUILD_ITEM, 9 stack args, return unused.
using BuildItem_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID,
const void *guid, const void *guid2, int a4,
int a5, int a6, int headerFlag, int a8, int a9);
BuildItem_t g_buildItemOriginal = nullptr;
void __fastcall BuildItem_h(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag,
int a8, int a9) {
g_buildItemOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
FireScript(self, SK_ITEM);
}
// Spell — FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP, 7 stack args, returns an int.
using BuildSpell_t = int(__fastcall *)(void *self, void *edx, int spellID, int a2,
int a3, int isPet, int a5, int a6, int a7);
BuildSpell_t g_buildSpellOriginal = nullptr;
int __fastcall BuildSpell_h(void *self, void *edx, int spellID, int a2, int a3,
int isPet, int a5, int a6, int a7) {
const int ret = g_buildSpellOriginal(self, edx, spellID, a2, a3, isPet, a5, a6, a7);
FireScript(self, SK_SPELL);
return ret;
}
// Unit — FUN_GAMETOOLTIP_BUILD_UNIT, 1 stack arg (guid), returns an int the
// caller (Script_GameTooltip_SetUnit) tests.
using BuildUnit_t = int(__fastcall *)(void *self, void *edx, const void *guid);
BuildUnit_t g_buildUnitOriginal = nullptr;
int __fastcall BuildUnit_h(void *self, void *edx, const void *guid) {
const int ret = g_buildUnitOriginal(self, edx, guid);
FireScript(self, SK_UNIT);
return ret;
}
// GameObject — FUN_GAMETOOLTIP_BUILD_GAMEOBJECT, 1 stack arg (guid), void.
using BuildGO_t = void(__fastcall *)(void *self, void *edx, const void *guid);
BuildGO_t g_buildGOOriginal = nullptr;
void __fastcall BuildGO_h(void *self, void *edx, const void *guid) {
g_buildGOOriginal(self, edx, guid);
FireScript(self, SK_GAMEOBJECT);
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _buildItemHook{
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
reinterpret_cast<void *>(&BuildItem_h),
reinterpret_cast<void **>(&g_buildItemOriginal)};
static const Game::HookAutoRegister _buildSpellHook{
Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP,
reinterpret_cast<void *>(&BuildSpell_h),
reinterpret_cast<void **>(&g_buildSpellOriginal)};
static const Game::HookAutoRegister _buildUnitHook{
Offsets::FUN_GAMETOOLTIP_BUILD_UNIT,
reinterpret_cast<void *>(&BuildUnit_h),
reinterpret_cast<void **>(&g_buildUnitOriginal)};
static const Game::HookAutoRegister _buildGOHook{
Offsets::FUN_GAMETOOLTIP_BUILD_GAMEOBJECT,
reinterpret_cast<void *>(&BuildGO_h),
reinterpret_cast<void **>(&g_buildGOOriginal)};
} // namespace
Suppressor::Suppressor() { ++g_suppress; }
Suppressor::~Suppressor() {
if (g_suppress > 0)
--g_suppress;
}
} // namespace Tooltip::SetEvents
@@ -13,12 +13,14 @@
#pragma once
namespace Tooltip::SetItemEvent {
namespace Tooltip::SetEvents {
// RAII guard that suppresses OnTooltipSetItem firing for the duration of an
// engine item-build we invoke *internally* (Tooltip::Compare building the
// equipped item into a shopping tooltip). Without it, a handler would run in
// the middle of our line-shift / delta rendering and see a half-built tooltip.
// RAII guard that suppresses tooltip set-event firing (OnTooltipSetItem etc.)
// for the duration of an engine build we invoke *internally* — currently
// Tooltip::Compare building the equipped item into a shopping tooltip. Without
// it, a handler would run in the middle of our line-shift / delta rendering and
// see a half-built tooltip. Only the item builder runs during that internal
// build, so in practice this gates OnTooltipSetItem; it's global for safety.
// Nestable (ref-counted).
struct Suppressor {
Suppressor();
@@ -27,4 +29,4 @@ struct Suppressor {
Suppressor &operator=(const Suppressor &) = delete;
};
} // namespace Tooltip::SetItemEvent
} // namespace Tooltip::SetEvents
-168
View File
@@ -1,168 +0,0 @@
// 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/>.
// `GameTooltip:HookScript("OnTooltipSetItem", fn)` — backport of the modern
// tooltip script that fires whenever a tooltip's item is set, so addons can
// annotate item tooltips by hooking one script instead of wrapping every
// Set* method (the pattern AtlasLoot resorts to in vanilla, rebuilding the
// tooltip and clobbering its item linkage). Fully native — a real frame
// script, settable via the standard SetScript / GetScript / HookScript.
//
// 1.12 already has the machinery for tooltip scripts, just not this one:
//
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
// all go through it. We co-hook it: for "OnTooltipSetItem" (a name the
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
// resolver's only contract is "return the address the script lives at", so
// external storage works exactly like an in-object slot.
//
// - Every item setter (SetHyperlink item:, SetBagItem, SetInventoryItem,
// SetMerchantItem, SetAuctionItem, …) funnels through the shared item
// builder FUN_GAMETOOLTIP_BUILD_ITEM. We co-hook it and, after the build,
// fire the handler via FUN_FRAME_RUN_SCRIPT (the same no-arg runner the
// clear uses for OnTooltipCleared) with the tooltip as `self`.
//
// Suppressed during our own Tooltip::Compare equipped-item build (see
// Suppressor), and guarded against a handler that re-enters the builder.
#include "SetItemEvent.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::SetItemEvent {
namespace {
// Per-tooltip 8-byte handler slot {handler, context}. Tooltips are few and
// permanent (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2, AtlasLootTooltip,
// WorldMapTooltip, plus the odd addon tooltip), so a small fixed table with a
// linear scan is plenty and gives each cell a stable address to hand out.
struct Cell {
void *tooltip;
uint32_t slot[2]; // [0] = handler, [1] = exec context (written by SetScript)
};
constexpr int kMaxTooltips = 32;
Cell g_cells[kMaxTooltips];
int g_cellCount = 0;
uint32_t *CellFor(void *tooltip, bool create) {
for (int i = 0; i < g_cellCount; ++i)
if (g_cells[i].tooltip == tooltip)
return g_cells[i].slot;
if (!create || g_cellCount >= kMaxTooltips)
return nullptr;
Cell &c = g_cells[g_cellCount++];
c.tooltip = tooltip;
c.slot[0] = 0;
c.slot[1] = 0;
return c.slot;
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
constexpr char kScriptNameLower[] = "ontooltipsetitem"; // matched case-insensitively
// --- Suppression / recursion state -------------------------------------
int g_suppress = 0; // > 0 while our own compare build runs
int g_firing = 0; // recursion guard around the handler fire
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
return engineSlot;
if (EqualsIgnoreCase(name, kScriptNameLower))
return reinterpret_cast<int>(CellFor(self, /*create*/ true));
return 0;
}
// --- Item-builder co-hook (fires the handler after the item is set) -----
using Build_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag,
int a8, int a9);
Build_t g_buildOriginal = nullptr;
// The engine's real script invoker: binds the global `this` = frame and runs
// the handler under its own protected lua_pcall. We call it directly rather
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
void __fastcall Build_h(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag, int a8,
int a9) {
g_buildOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
if (g_suppress > 0 || g_firing > 0)
return;
uint32_t *slot = CellFor(self, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
// We fire from inside the item builder, which is itself mid-execution of
// the engine's Script_Set* (and, in the wild, nested under addon Set*
// wrappers + several other DLLs' hooks). The invoker pushes onto the Lua
// stack and does NOT restore the top to where the outer, still-running C
// code left it — harmless from the engine's top-level dispatch, but from
// here it shifts the stack and the outer code (and other DLLs' hooks) then
// index garbage → deref → crash. Snapshot and restore the stack top around
// the fire so the outer code sees exactly the stack it left.
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
++g_firing;
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
--g_firing;
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _buildHook{
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
reinterpret_cast<void *>(&Build_h),
reinterpret_cast<void **>(&g_buildOriginal)};
} // namespace
Suppressor::Suppressor() { ++g_suppress; }
Suppressor::~Suppressor() {
if (g_suppress > 0)
--g_suppress;
}
} // namespace Tooltip::SetItemEvent
+17 -12
View File
@@ -77,25 +77,30 @@ int __fastcall Script_UnitClassBase(void *L) {
// the in-world descriptor exists), so this resolves at addon-load
// instead of returning nil until the player object spawns. All other
// tokens go through the unit descriptor.
uint8_t classByte;
uint8_t classByte = 0;
if (Unit::Identity::IsPlayerToken(token)) {
classByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_CLASS_BYTE);
} else {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
if (unit != nullptr) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
}
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
// No live descriptor (out of range / different map) → fall back to the
// engine's player-info cache, matching what UnitClass itself does out
// of range (class at OFF_PLAYER_INFO_CLASS). `token` is already valid
// here — resolve() raises on a bad token before this — so GuidForToken
// won't raise.
if (classByte == 0) {
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
Unit::Identity::GuidForToken(token));
if (rec != nullptr)
classByte = static_cast<uint8_t>(*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_PLAYER_INFO_CLASS));
}
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
}
if (classByte == 0) {
Game::Lua::PushNil(L);
+83 -18
View File
@@ -64,6 +64,12 @@ const Event::Custom::AutoReserve _reserve{kEventName};
uint64_t g_focusGUID = 0;
// True while the `"focus"` descriptor-field observers are currently registered
// on a LIVE focus object. Cleared when the object despawns (the nodes die with
// it) so `OnWorldTick` re-registers when a groupmate returns to range; guards
// against double-registering while the object stays resolvable.
bool g_focusObserved = false;
// Descriptor-field observer callback — fires the changed field's unit event
// with the `"focus"` token, making `arg1 == "focus"` work for `UNIT_HEALTH`,
// `UNIT_MANA`, `UNIT_AURA`, … exactly like a native token. Registered per
@@ -114,28 +120,79 @@ uint64_t ResolveTokenGUID(const char *token) {
return fn(token);
}
// Per-tick despawn watcher. Probes the engine's object table for
// `g_focusGUID`; when it disappears (out of range, fully despawned),
// clear focus and fire PLAYER_FOCUS_CHANGED — matches modern's
// "leaves render distance → focus drops" behavior. Won't refocus
// when the unit comes back, also matching modern.
// Resolve a GUID to its live CGUnit via the object manager, or null if it
// isn't currently in the client's object table (out of range / despawned /
// pre-world). Non-throwing — safe from any context.
const uint8_t *ResolveObject(uint64_t guid) {
if (guid == 0)
return nullptr;
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "Focus", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0x172));
}
// True if `guid` is a current party or raid member. Reads the roster GUID
// storage directly — the 4-slot party GUID array and the 40-slot raid
// member-pointer array (GUID at `*member + 0`) — which are populated from the
// group roster independent of the object table, so this answers correctly for
// a groupmate whose CGUnit has been destroyed by leaving your range. Mirrors
// 3.3.5's focus-clear gate `FUN_00512a30` (= `FUN_0052d310` party-member OR
// `FUN_00573200` raid-member).
bool IsGroupMemberGuid(uint64_t guid) {
if (guid == 0)
return false;
const auto *party = reinterpret_cast<const uint64_t *>(
static_cast<uintptr_t>(Offsets::VAR_PARTY_GUIDS));
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i)
if (party[i] == guid)
return true;
const int raidCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_COUNT));
if (raidCount > 0) {
const auto *const *raid = reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_PTRS));
for (int i = 0; i < Offsets::RAID_MAX_SLOTS; ++i) {
const uint8_t *m = raid[i];
if (m != nullptr && *reinterpret_cast<const uint64_t *>(m) == guid)
return true;
}
}
return false;
}
// Per-tick focus watcher. Resolving `g_focusGUID` from the object table is
// both the despawn check and (when alive) the `focustarget` read.
//
// When the object is gone (out of range, LoS, despawn) we mirror 3.3.5's focus
// gate: a **party/raid member keeps focus** — their CGUnit is destroyed when
// they leave your range, but the roster GUID persists, so focus reattaches
// when they return — while any **other** unit drops focus, exactly as
// vanilla/3.3.5 do on a real despawn. (Verified against 3.3.5's object-teardown
// purge `FUN_00524350`, gated by the party-or-raid check `FUN_00512a30`.)
// Vanilla fires no re-target event, so this once-per-tick read is also how
// `focustarget` follows the focus switching targets; its events stay
// observer-driven below.
void OnWorldTick() {
// Resolve the focus object (this is also the despawn check) and, if it's
// alive, read its current target — that GUID is `focustarget`. Vanilla
// fires no event when a unit re-targets, so this once-per-tick identity
// read is the only way to notice the focus switching targets; the
// focustarget *events* still fire event-driven via the observer below.
uint64_t desiredFocusTarget = 0;
if (g_focusGUID != 0) {
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
auto *focusObj = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
static_cast<uint32_t>(g_focusGUID),
static_cast<uint32_t>(g_focusGUID >> 32), 0x172));
const uint8_t *focusObj = ResolveObject(g_focusGUID);
if (focusObj == nullptr) {
Set(0); // focus left the object table → drop focus
if (IsGroupMemberGuid(g_focusGUID)) {
// Groupmate out of range: keep focus. Its observers died with
// the object; OnWorldTick re-registers them when it resolves.
g_focusObserved = false;
} else {
Set(0); // non-group unit left the object table → drop focus
}
} else {
// (Re)attach the unit-event observers on the live object — covers a
// groupmate returning to range, and a focus set while out of range.
if (!g_focusObserved) {
TokenObserver::Register(g_focusGUID, &FocusFieldCb);
g_focusObserved = true;
}
auto *fields = *reinterpret_cast<const uint8_t *const *>(
focusObj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (fields != nullptr)
@@ -199,6 +256,7 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
void ClearOnLogout() {
g_focusGUID = 0;
g_focusTargetGUID = 0;
g_focusObserved = false;
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
@@ -215,6 +273,7 @@ void Set(uint64_t guid) {
// old unit already despawned (nodes died with it).
if (g_focusGUID != 0)
TokenObserver::Unregister(g_focusGUID, &FocusFieldCb);
g_focusObserved = false;
// Focus changed → the old focustarget is stale; drop its observer now.
// WorldTick re-resolves and re-points to the new focus's target next tick.
if (g_focusTargetGUID != 0) {
@@ -222,8 +281,14 @@ void Set(uint64_t guid) {
g_focusTargetGUID = 0;
}
g_focusGUID = guid;
if (guid != 0)
// Register the unit-event observers only against a LIVE object. If the new
// focus is currently out of range (e.g. /focus a raid member across the
// map), OnWorldTick registers once it resolves — keeping the observers tied
// to a real object instance rather than an absent GUID.
if (guid != 0 && ResolveObject(guid) != nullptr) {
TokenObserver::Register(guid, &FocusFieldCb);
g_focusObserved = true;
}
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
+12 -1
View File
@@ -24,6 +24,7 @@
#include "Game.h"
#include "Offsets.h"
#include "group/MemberStats.h"
#include <cstdint>
@@ -57,7 +58,17 @@ static int __fastcall Script_UnitHealthMissing(void *L) {
}
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
if (desc == nullptr) {
Game::Lua::PushNumber(L, 0.0);
// No live object → out-of-range group member; the group roster caches
// HP (same fallback the engine's UnitHealth uses). Reached only after
// FUN_RESOLVE_UNIT_TOKEN returned null on an already-valid token, so
// TokenToGUID can't raise.
auto tokenToGuid = reinterpret_cast<uint64_t(__fastcall *)(const char *)>(
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
const Group::MemberStats::Stats s =
Group::MemberStats::Lookup(tokenToGuid(Game::Lua::ToString(L, 1)));
const uint32_t missing =
(s.valid && s.maxHealth > s.health) ? s.maxHealth - s.health : 0u;
Game::Lua::PushNumber(L, static_cast<double>(missing));
return 1;
}
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
+18
View File
@@ -164,6 +164,24 @@ uint64_t GuidForToken(const char *token) {
return fn(token);
}
const uint8_t *PlayerInfoRecord(uint64_t guid) {
if (guid == 0)
return nullptr;
// Peek the engine's player-info NameCache: a NULL callback means "look up
// only, don't fire a query" (see the FUN_PLAYER_INFO_LOOKUP note in
// Offsets.h). Returns the entry data block for a cached GUID, else null.
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
uint32_t hi, void *cookie,
void *cb, void *ud, char flag);
auto fn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
uint32_t cookie[2] = {0, 0};
return fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
cookie, nullptr, nullptr, 0);
}
// `UnitGUID(unit)` — returns the unit's 64-bit GUID formatted as a
// hex string `"0xHHHHHHHHLLLLLLLL"` (16 hex digits, hi dword first).
//
+9
View File
@@ -86,4 +86,13 @@ uint64_t GuidForToken(const char *token);
// `buf` should be at least 32 bytes.
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
// The engine's cached player-info record for `guid` — the NameCache data block
// (name @ `OFF_PLAYER_INFO_NAME`, realm, race @ `_RACE`, sex, class @ `_CLASS`),
// or null if the GUID isn't cached. Object-independent: it resolves players who
// are out of range / on another map / have left the group, since the cache is
// populated on sync (SMSG_NAME_QUERY_RESPONSE). The single primitive for "what
// do we know about this player GUID" — read fields via the `OFF_PLAYER_INFO_*`
// offsets. Called with a NULL callback, so it only peeks (never fires a query).
const uint8_t *PlayerInfoRecord(uint64_t guid);
} // namespace Unit::Identity
+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
+48 -3
View File
@@ -34,6 +34,7 @@
#include "Game.h"
#include "Offsets.h"
#include "group/MemberStats.h"
#include "unit/Power.h"
#include <cstdint>
@@ -140,6 +141,49 @@ bool ResolveArgs(void *L, const uint8_t **outDesc, int *outType) {
return true;
}
// Out-of-range group-member fallback. When a unit token has no live CGUnit —
// a party/raid member on a different map or otherwise out of range — the
// descriptor is gone, but the group roster still caches the member's PRIMARY
// power (`Group::MemberStats`, fed by SMSG_PARTY_MEMBER_STATS). This mirrors
// what the engine's Script_UnitMana / Script_UnitManaMax do. Reached only
// after ResolveArgs fails (no live object) — the token is already known valid,
// so TokenToGUID won't raise. Returns 0 if the GUID isn't rostered, or if an
// explicit powerType other than the member's primary was requested (the roster
// holds only the one power).
enum class RosterKind { Current, Max, Missing };
double RosterPowerFallback(void *L, RosterKind kind) {
if (!Game::Lua::IsString(L, 1))
return 0.0;
auto tokenToGuid = reinterpret_cast<uint64_t(__fastcall *)(const char *)>(
static_cast<uintptr_t>(Offsets::FUN_TOKEN_TO_GUID));
const Group::MemberStats::Stats s =
Group::MemberStats::Lookup(tokenToGuid(Game::Lua::ToString(L, 1)));
if (!s.valid)
return 0.0;
const int reqType = Game::Lua::IsNumber(L, 2)
? static_cast<int>(Game::Lua::ToNumber(L, 2))
: -1;
if (reqType >= Offsets::UNIT_POWER_MIN_TYPE &&
reqType <= Offsets::UNIT_POWER_MAX_TYPE && reqType != s.powerType)
return 0.0;
const uint32_t divisor =
Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(s.powerType);
const uint32_t curDisp = s.power / divisor;
const uint32_t maxDisp = s.maxPower / divisor;
switch (kind) {
case RosterKind::Current:
return static_cast<double>(curDisp);
case RosterKind::Max:
return static_cast<double>(maxDisp);
case RosterKind::Missing:
return static_cast<double>(maxDisp > curDisp ? maxDisp - curDisp : 0u);
}
return 0.0;
}
// `UnitPower("unit" [, powerType])` — returns the current power
// value for the given type. Defaults to the unit's primary power
// when `powerType` is omitted or set to the modern -1 sentinel.
@@ -153,7 +197,8 @@ static int __fastcall Script_UnitPower(void *L) {
const uint8_t *desc;
int type;
if (!ResolveArgs(L, &desc, &type)) {
Game::Lua::PushNumber(L, 0.0);
// No live object → out-of-range group member; try the roster cache.
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Current));
return 1;
}
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
@@ -176,7 +221,7 @@ static int __fastcall Script_UnitPowerMax(void *L) {
const uint8_t *desc;
int type;
if (!ResolveArgs(L, &desc, &type)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Max));
return 1;
}
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
@@ -206,7 +251,7 @@ static int __fastcall Script_UnitPowerMissing(void *L) {
const uint8_t *desc;
int type;
if (!ResolveArgs(L, &desc, &type)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Missing));
return 1;
}
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
+17 -12
View File
@@ -57,25 +57,30 @@ int __fastcall Script_UnitRaceBase(void *L) {
// the in-world descriptor exists), so this resolves at addon-load
// instead of returning nil until the player object spawns. All other
// tokens go through the unit descriptor.
uint8_t raceByte;
uint8_t raceByte = 0;
if (Unit::Identity::IsPlayerToken(token)) {
raceByte = *reinterpret_cast<const uint8_t *>(Offsets::VAR_PLAYER_RACE_BYTE);
} else {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
if (unit != nullptr) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
}
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
// No live descriptor (out of range / different map) → fall back to the
// engine's player-info cache, matching what UnitRace itself does out of
// range (race at OFF_PLAYER_INFO_RACE). `token` is already valid here —
// resolve() raises on a bad token before this — so GuidForToken won't
// raise.
if (raceByte == 0) {
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
Unit::Identity::GuidForToken(token));
if (rec != nullptr)
raceByte = static_cast<uint8_t>(*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_PLAYER_INFO_RACE));
}
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
}
if (raceByte == 0) {
Game::Lua::PushNil(L);