Compare commits

...
54 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
Brues 5f13c63c99 unit: add UnitSpellHaste(unit) -- backport of the TBC+ haste getter
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:

    haste% = (1 / modCastSpeed - 1) * 100

so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).

Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).

New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
2026-07-25 00:27:17 -05:00
Brues afa1f62720 unit: add the unmodified arg to UnitPower / UnitPowerMax
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.

Verified in-game on a warrior (rage, divisor 10):
  UnitPower(1)=96  UnitPower(1,true)=961   (floor(961/10)=96)
  UnitPowerMax(1)=100  UnitPowerMax(1,true)=1000
  UnitPowerMissing(1)=4  UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.

Docs updated (signature, TOC anchor, unmodified note).
2026-07-25 00:10:09 -05:00
Brues c369373efe unit: add UnitPowerMissing(unit [, powerType [, unmodified]])
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.

Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.

Docs + README updated.
2026-07-25 00:04:16 -05:00
Brues 1c86d0dead unit: add UnitHealthMissing(unit)
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.

Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
2026-07-24 23:58:46 -05:00
Brues cedbd04553 cast: mirror the server's +500ms ranged aim time in the cast-time prediction
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.

CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
2026-07-24 23:21:26 -05:00
Brues 3b1bba2131 cast: reconcile local-player cast time to the server on the confirming packet
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").

SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.

Closes https://github.com/brues-code/pfUI/issues/28
2026-07-24 22:40:27 -05:00
Brues 786428c9fb focus/who: reset per-character state on logout
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.

Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.

Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
2026-07-24 22:29:05 -05:00
Brues f5ecdc69c5 equipmentset: reload sets per character, not once per process
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.

Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.

Fixes https://github.com/brues-code/pfUI/issues/29
2026-07-24 22:13:55 -05:00
Brues 7531223b9a nameplate: assign stable slots instead of a compacting list
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.

g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).

Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
2026-07-24 18:45:42 -05:00
Brues cb4e428592 unit: fire per-token unit events for focustarget
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.

Verified in-game: focustarget events fire and re-point when the focus
switches targets.
2026-07-24 18:13:08 -05:00
Brues e7e2fe05dc unit: guard TokenObserver against a null observer-registry root
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).

The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.

Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
2026-07-24 18:10:42 -05:00
Brues d7078827fd unit: fire per-token unit events for focus and nameplateN
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.

Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.

New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).

- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
  and (all) in PrepareForReload, since objects survive /reload; the
  callback resolves the changed guid to its current plate index at fire
  time.

Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
2026-07-24 17:26:23 -05:00
Brues c571c1a852 equipmentset: build the tooltip natively, not via Lua method dispatch
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.

Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
2026-07-23 22:38:12 -05:00
Brues a7b205cd56 totem: add GameTooltip:SetTotem(slot)
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().

The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
2026-07-23 22:38:00 -05:00
Brues a3f224876f event: add a correctly-timed on-demand table-grow valve
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.

Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.

Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
2026-07-23 21:27:29 -05:00
Brues a40236eb84 spell: add GameTooltip:AddSpellByID(spellID)
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).

BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
2026-07-23 21:12:06 -05:00
Brues 538beda499 event: remove the mis-timed EnsureCapacity table-grow valve
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.

Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
2026-07-23 21:06:36 -05:00
Brues af01a4b408 event: claim NULL slots low-first + on-demand table expansion
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.

- Claim NULL-name slots walking LOW->high instead of from the tail.
  SuperWoW/nampower own the high range (fixed IDs 549..655) and
  destructively clamp the table to 700, and VanillaMinimapTracking also
  walks the tail, so the top is contested; the engine's low gaps (~200,
  incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
  in-game: our events now land at 1..64, VMT alone at 699 -- clean
  separation, independent of the clamp.

- Add Event::Custom::EnsureCapacity: on-demand grow run once from
  LoadScriptFunctions_h (after module registration so all reservations
  incl. lazy TTS are counted, before any frame registers). If NULL slots
  < reserved, it reallocates the entry buffer via Storm and swaps the
  {cap,count,base} globals directly, bypassing SetEventCount's clamp --
  so it's load/hook-order independent. Defensive: reads fresh, verifies
  all chains empty before moving the buffer (aborts otherwise), frees
  only the old buffer (names are shared), publishes base before count.
  Dormant in the common case (low gaps dwarf our count).

- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
  on a SuperWoW/nampower-contended function. Keeps the one-shot
  table-full log as the backstop.

Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
2026-07-23 18:13:41 -05:00
Brues 33c8c3e37c Add C_TaxiMap.GetTaxiRoute(slotIndex)
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.

Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.

Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
2026-07-23 06:00:55 -05:00
Brues c5d5415afe taxi: add C_TaxiMap.GetTaxiPaths + GetTaxiPathWaypoints
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).

- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
  directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
  coordinates ordered by node index; sum the 3D chords for path length
  (/ 32 yd/s ~= single-hop flight seconds).

Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
2026-07-23 03:59:04 -05:00
67 changed files with 5693 additions and 763 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
+10 -7
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: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: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,18 +59,18 @@ 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` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [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`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `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` |
+800 -106
View File
File diff suppressed because it is too large Load Diff
+226
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
@@ -664,6 +681,25 @@ enum Offsets {
// The live (new) value is NOT passed — read it from the object (the
// engine's bag callback reads the invMgr GUID array).
// ---- Unit-event token observers (Unit::TokenObserver) ---------------
// The engine makes target/party/raid/pet/mouseover units fire per-token
// unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA, …) by watching bank-UNIT
// descriptor fields: FUN_0051bbb0 loops event index i in
// [0, EVENT_NAME_UNIT_MAX); for each whose static name slot is non-null it
// registers a bank-3 observer on field i*4 (per-index size below) with
// callback FUN_0051bd50 → the broadcast FUN_00515e50(guid, i), which fires
// event i once per token referencing the guid. The FIELD INDEX IS THE EVENT
// ID (health field +0x40 → event 16 = UNIT_HEALTH). Unit::TokenObserver
// replicates that exact loop with a caller's own callback to make synthetic
// tokens (focus, nameplateN) first-class; the registrar always appends a
// fresh node (FUN_00467f00, no dedup), so ours coexists with the engine's —
// a unit that is also the target fires both "target" and our token. The
// broadcast always fires with format "%s" + the token (DAT_0082e280).
// Per-index watched size (FUN_0051bbb0's switch): i∈{0,2,4,10}=8,
// 0x29=0xD8 (UNIT_FIELD_AURA), 0x6B=0x30, 0xA7/0xAE=0x1C, else 4.
VAR_EVENT_NAME_TABLE_STATIC = 0x00BE1198, // char*[] boot name array; slot i (base+i*4) non-null ⇒ real event i
EVENT_NAME_UNIT_MAX = 0xB6, // FUN_0051bbb0's loop bound
// The engine's inventory observer setup — registers the bag-slot
// GUID-field observers above, once per enter-world (sole caller is the
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
@@ -679,6 +715,7 @@ enum Offsets {
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
DESC_PLAYER_EQUIP_SLOTS = 19,
DESC_OBSERVER_BANK_PLAYER = 4,
DESC_OBSERVER_BANK_UNIT = 3, // CGUnit descriptor bank (FUN_0051bbb0's watch loop)
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
@@ -806,6 +843,15 @@ enum Offsets {
// mana/rage/focus/energy/happiness order as POWER1..5). Vanilla
// 1.12 has 5 power types; the WotLK additions (Runes / Runic Power)
// don't exist in this descriptor layout.
// UNIT_MOD_CAST_SPEED — the cast-time multiplier float (1.0 = normal,
// <1.0 = faster). The server folds it into `SpellEntry::GetCastTime`
// (`castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`); the client reads it
// in `FUN_006e3340` at descriptor `+0x22c`. Backs `UnitSpellHaste`
// (`haste% = (1/mult - 1) * 100`). nampower surfaces the raw float as its
// `modCastSpeed` unit field.
OFF_UNIT_MOD_CAST_SPEED = 0x22C,
UNIT_POWER_MIN_TYPE = 0,
UNIT_POWER_MAX_TYPE = 4, // happiness; types 5/6 only valid post-WotLK
@@ -1268,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,
@@ -2438,6 +2514,29 @@ enum Offsets {
// "DISTANT" / other) — the node's reachability for the open session.
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
// Per-slot computed route (the chain that TakeTaxiNode sends to the
// server). Derived from FUN_004dc8d0 (the TakeTaxiNode worker) and the
// route builder FUN_004dbce0:
// +0x0C (byte) nonzero when the multi-hop route array below is built
// +0x14 (int*) array of TaxiNodes.dbc IDs, current->dest in flight
// order, length at +0x18
// +0x18 (int) route length (node count; hops = count - 1)
// A direct hop (a TaxiPath edge exists current->dest) is flown as just
// {current, dest} and does NOT populate +0x14 — see VAR_TAXI_ADJACENCY.
OFF_TAXI_SLOT_MULTIHOP = 0x0C,
OFF_TAXI_SLOT_ROUTE = 0x14,
OFF_TAXI_SLOT_ROUTE_COUNT = 0x18,
// Pointer to the CURRENT flight master's TaxiNodes.dbc record (id @ +0);
// the origin of every route. Used by FUN_004dc8d0 / FUN_004dbce0.
VAR_TAXI_CURRENT_REC = 0x00BB4A80,
// Node x node -> pathID adjacency matrix (256-wide rows, int cells; cell
// [from*0x100 + to] is the direct TaxiPath id, or 0 if none). Backs the
// direct-hop test FUN_004dbad0. IDs are 1..256.
VAR_TAXI_ADJACENCY = 0x00B7365C,
TAXI_ADJACENCY_DIM = 0x100,
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
// cost}. A real flight master is a node some flight path connects to;
// Northshire Abbey / spurious duplicate rows / standalone markers are not
@@ -2446,6 +2545,23 @@ enum Offsets {
VAR_TAXIPATH_COUNT = 0x00C0D69C,
OFF_TAXIPATH_FROM = 0x04,
OFF_TAXIPATH_TO = 0x08,
OFF_TAXIPATH_COST = 0x0C,
// TaxiPathNode.dbc — the per-path flight waypoints. 1-based
// record-pointer array (records/count slots per docs/DBCs.md). Record
// (verified against the extracted DBC + the loading-screen spline
// reader FUN_004075A0, which reads mapID@+0xC, X@+0x10, Y@+0x14):
// {ID@+0, pathID@+4, nodeIndex@+8, mapID@+0xC,
// X@+0x10, Y@+0x14, Z@+0x18 (float), flags@+0x1C, delay@+0x20}
// Waypoints of a path share pathID and order by nodeIndex.
VAR_TAXIPATHNODE_RECORDS = 0x00C0D6AC,
VAR_TAXIPATHNODE_COUNT = 0x00C0D6B0,
OFF_TAXIPATHNODE_PATH_ID = 0x04,
OFF_TAXIPATHNODE_INDEX = 0x08,
OFF_TAXIPATHNODE_MAP = 0x0C,
OFF_TAXIPATHNODE_X = 0x10,
OFF_TAXIPATHNODE_Y = 0x14,
OFF_TAXIPATHNODE_Z = 0x18,
// Engine player-info cache — populated by the
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
@@ -3710,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
@@ -3947,6 +4117,10 @@ enum Offsets {
// the frame into the matching entry's chain.
VAR_EVENT_TABLE_BASE_PTR = 0x00CEEF68,
VAR_EVENT_TABLE_COUNT = 0x00CEEF64,
// The FrameScript_EventObject is a `{cap@+0, count@+4, data@+8}` at
// 0x00CEEF60, so COUNT/BASE above are its +4/+8. `Event::Custom::Grow`
// writes all three (base, cap, count) when it enlarges the table.
VAR_EVENT_TABLE_CAP = 0x00CEEF60,
EVENT_ENTRY_STRIDE = 0x10,
OFF_EVENT_ENTRY_NAME = 0x00,
OFF_EVENT_ENTRY_HEAD = 0x0C,
@@ -3987,6 +4161,17 @@ enum Offsets {
// table after the rebuild settles. Kept for reference.
FUN_REBUILD_EVENT_TABLE = 0x00703D90,
// Event-table allocator — `__thiscall(EventObject *this, uint32 count)`.
// Called by `RebuildEventTable` to (re)size the entry array. SuperWoW and
// nampower co-hook it to destructively clamp the FrameXML count (`> 200`;
// glue is smaller) to 700. We deliberately DON'T hook it: our default is
// claim-NULL into the engine's ~200 low gaps (which always suffices), and
// when a grow IS needed `Event::Custom::Grow` reallocates the buffer +
// swaps the globals directly (bypassing this clamp) rather than routing a
// size through here. Also documents the empty-entry init the grow mirrors
// ({name=0, +4=0, +8=&self, +0xC=(&self)|1}). Kept for reference.
FUN_FRAMESCRIPT_SET_EVENT_COUNT = 0x007053B0,
// `FireEvent` — the engine's event dispatcher. 149 callers in the
// binary. `__cdecl void(int eventID, const char *format, ...)`.
// Indexes into `[VAR_EVENT_TABLE_BASE_PTR] + eventID * 0x10` and walks
@@ -4200,6 +4385,16 @@ enum Offsets {
// from `SMemAlloc` (which it requires).
FUN_STORM_SSTRDUP = 0x0064A620,
// Storm allocator/free — the pair the engine uses for the event-table
// buffer, so its reload teardown `SMemFree`s our grown buffer as its own.
// Both `__stdcall`, 4 args (`ret 0x10`); SMemAlloc flag bit 8 (`& 8`)
// zero-fills. `Event::Custom::Grow` uses them to reallocate the event
// buffer. (Same addresses as FUN_STORM_SMEM_ALLOC / _FREE above.)
// void* __stdcall SMemAlloc(uint size, const char *file, int line, int flags)
// char __stdcall SMemFree (void *ptr, const char *file, int line, int flags)
FUN_STORM_SMEMALLOC = 0x006462E0,
FUN_STORM_SMEMFREE = 0x00646430,
// Talent system — per-player talent state populated at login from
// (class, race) + Talent.dbc / TalentTab.dbc. The engine maintains
// one `TabInfo *` per talent tab in the array at
@@ -4261,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` /
@@ -5148,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
+20 -5
View File
@@ -88,12 +88,27 @@ void Persist() {
} // namespace
void EnsureLoaded() {
if (g_loaded)
return;
g_path = Storage::ResolveFilePath();
if (g_path.empty())
return; // session globals not yet populated; retry on next call
// Re-resolve the per-character path on every call rather than latching
// once. The injected DLL persists across a logout→character-select→login
// (no process restart), so a permanent `g_loaded` latch would leave the
// previous character's file path and cached sets in place — the next
// character would then read, and save into, character A's data. Detecting
// a path change here makes the cache self-heal on any character/account
// switch. Every mutation Persist()s immediately, so dropping the old
// in-memory sets never loses unsaved work.
std::string path = Storage::ResolveFilePath();
if (path.empty())
return; // session globals not yet populated; keep current, retry later
if (g_loaded && path == g_path)
return; // already loaded for this character
g_path = std::move(path);
g_sets.clear();
Storage::Load(g_path, &g_sets);
// Per-session "ignore this slot on next save" state belongs to the
// character it was set on — clear it when switching characters.
for (int i = 0; i < SLOT_COUNT; ++i)
g_ignoredForSave[i] = false;
g_loaded = true;
}
+5 -3
View File
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
// Resolves the per-character file path. Returns an empty string until
// the engine has populated all three session globals (account, realm,
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
// world the values are stable for the rest of the session, so callers
// can cache after the first non-empty return.
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
// current character is in world — they change on a logout→login to a
// different character (the DLL is NOT reloaded across that transition),
// so callers must re-resolve and compare rather than caching the first
// non-empty return permanently. See `Data::EnsureLoaded`.
std::string ResolveFilePath();
// Reads the file at `path` and overwrites `outSets` with the parsed
+50 -66
View File
@@ -40,11 +40,12 @@
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
// themselves.
//
// Tooltip filling is done by calling back through Lua (`self:AddLine`
// etc.) rather than hitting the engine's tooltip-line internals
// directly. The Lua dispatch goes through the engine's own frame-
// method registry, so any addon-installed hook on AddLine (DBM,
// pfUI's tooltip skin, etc.) still runs.
// Tooltip lines are built with the engine's own primitives — the
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
// anchor then calls SetEquipmentSet with no trailing :Show().
#include "Game.h"
#include "Offsets.h"
@@ -58,57 +59,41 @@ namespace EquipmentSet::Tooltip {
namespace {
// Calls `self:Method()` with no Lua args. Stack at entry must have
// the tooltip at index 1 (the standard `frame:method()` self slot).
// Leaves the stack unchanged.
void CallSelfNoArgs(void *L, const char *methodName) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, methodName);
Game::Lua::GetTable(L, -2); // method = self[methodName]
Game::Lua::Insert(L, -2); // [method, self]
Game::Lua::Call(L, 1, 0);
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
// line-color setter reads. Alpha is always opaque.
uint32_t Pack(double r, double g, double b) {
auto ch = [](double v) {
const int i = static_cast<int>(v * 255.0 + 0.5);
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
};
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
}
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
void AddLine(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "AddLine");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0);
// Left-aligned colored line via the engine's raw add-line. The packed
// color is a local so its address stays valid across the call.
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
const uint32_t color = Pack(r, g, b);
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
// the header line — `SetText` is the standard vanilla entry for
// "first line of a fresh tooltip" and handles internal redraw state
// `AddLine` skips when it's the first content after a clear.
void SetText(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "SetText");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
}
// Format-and-add one localized count line. Calls
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
double r, double g, double b) {
// Adds one localized "N unit" line: `string.format(_G[globalName] or
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
// with the C `fallback` for servers stripped of the standard strings),
// then the native colored add-line.
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
const char *fallback, int n, double r, double g, double b) {
const int savedTop = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
const char *text = Game::Lua::ToString(L, -1);
if (text != nullptr)
AddLine(L, text, r, g, b);
AddColoredLine(self, text, r, g, b);
Game::Lua::SetTop(L, savedTop);
}
@@ -185,39 +170,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (s == nullptr)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const Tally t = TallySet(*s);
const int totalSlots = t.equipped + t.inInventory + t.missing;
// Trim the Lua stack to just the tooltip self-arg so our internal
// method-call sequences don't accumulate the original args past
// their useful lifetime. `setName` was a `const char *` lifted out
// by ToString — Lua's strings are pinned by reference count, so
// dropping the string from the stack doesn't invalidate the
// pointer for the lifetime of this call.
Game::Lua::SetTop(L, 1);
// Header — set name, white. Doubles as the "ClearLines" boundary;
// SetText replaces line 1 and clears subsequent lines in vanilla.
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
// Header — set name, white. Clear first so it lands as line 0
// (the clear resets the line count the add-line body indexes from).
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
// Body lines — try Blizzard's localized FORMAT_* globals first.
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
// override by reassigning `_G[name]` at runtime. The fallbacks
// only fire on servers stripped of the standard GlobalStrings.
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
totalSlots, 1.0, 1.0, 1.0);
if (t.equipped > 0) {
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
t.equipped, 0.0, 1.0, 0.0); // green
}
if (t.inInventory > 0) {
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
t.inInventory, 1.0, 1.0, 1.0);
}
if (t.ignored > 0) {
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
t.ignored, 0.5, 0.5, 0.5); // gray
}
// Missing slots: list each by name using the itemID we stored at
@@ -255,18 +237,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
Game::Lua::Call(L, 2, 1);
const char *line = Game::Lua::ToString(L, -1);
if (line != nullptr)
AddLine(L, line, 1.0, 0.0, 0.0); // red
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
Game::Lua::SetTop(L, savedTop);
}
if (unnamedMissing > 0) {
// Pre-itemID-format files or items the cache has no record of.
// No Blizzard format global maps cleanly here; addons can
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
}
CallSelfNoArgs(L, "Show");
// Show ourselves — self is still at Lua stack index 1 (Script_Show
// reads it there). Retail's SetEquipmentSet shows internally too.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
+178 -7
View File
@@ -46,6 +46,22 @@ struct ReservedName {
ReservedName g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
bool g_writesEnabled = false;
// One-shot latch so a genuinely full event table (no NULL slot left to
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
// One grow attempt per in-game table build. Reset in `PrepareForReload`
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
bool g_growLatched = false;
// Debug: force a grow on the next table build regardless of headroom, so
// the (otherwise dormant) grow path can actually be exercised in-game.
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
bool g_forceGrowNext = false;
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
// and is exactly what the engine itself does when filling event table
@@ -61,14 +77,38 @@ char *SStrDup(const char *s) {
return fn(s, __FILE__, __LINE__);
}
// Walk the engine's event table from the END looking for a NULL-name
// Storm allocator/free — the same pair the engine uses for the event-table
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
void *SMemAlloc(unsigned size) {
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
int line, int flags);
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
}
void SMemFree(void *ptr) {
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
int flags);
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
fn(ptr, __FILE__, __LINE__, 0);
}
// Walk the engine's event table from the LOW end looking for a NULL-name
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
// through the 548-slot input names array (it only writes ~350 of the
// slots explicitly; the rest stay zero-initialized from .data). UP
// and DOWN walks both find these gaps — we go DOWN for convention with
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
// shows our events grouped at the tail, visually distinct from engine
// events.
// slots explicitly; the rest stay zero-initialized from .data) — a big
// contiguous hole around 42..106 alone dwarfs our reservation count.
//
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
// range (fixed event IDs 549..655, and they destructively clamp the table
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
// from the tail — so the top is the contested end. Claiming from the low
// engine gaps keeps us entirely out of their way and independent of their
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
// reservations, so a free slot is essentially always available; the
// skip-occupied invariant keeps this safe against future engine additions
// regardless of direction.
int TryClaim(const char *eventName) {
if (!g_writesEnabled)
return -1;
@@ -76,7 +116,7 @@ int TryClaim(const char *eventName) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = count - 1; i >= 0; --i) {
for (int i = 0; i < count; ++i) {
auto **namePtr = reinterpret_cast<const char **>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
@@ -88,9 +128,119 @@ int TryClaim(const char *eventName) {
return i;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
if (!g_warnedTableFull) {
g_warnedTableFull = true;
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
}
return -1;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
// table (frames can only register once it exists) but BEFORE any frame has
// joined a chain (it's the first registration, and we run before the
// original registers it). So every chain is still an empty self-sentinel,
// and the buffer can be moved safely.
//
// (This is what the removed `EnsureCapacity` got wrong: it ran from
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
// previous/glue table and always aborted — it never actually grew. The
// realloc logic itself was fine; only the timing was broken.)
//
// Grows only when the engine's low NULL gaps are fewer than our
// reservations — which never happens in practice (~200 gaps vs ~46
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
// (debug) forces it so the path can be exercised. It grows by reallocating
// the entry buffer with the engine's own Storm allocator and swapping the
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
// our buffer and frees the (shared) name strings via its per-entry helper,
// exactly as its own.
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
return;
int nulls = 0;
for (int i = 0; i < count; ++i)
if (*reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
++nulls;
const bool force = g_forceGrowNext;
g_forceGrowNext = false;
if (nulls >= g_reservedCount && !force)
return; // plenty of room — the common (only) real case
// Growing moves the buffer, invalidating every entry's self-referential
// list anchor, so it's safe ONLY while all chains are empty. We run
// before the first registration, so that holds — but VERIFY it (a live
// chain means someone registered earlier via a path we didn't intercept;
// moving the buffer would corrupt their subscription). Abort if so and
// fall back to claim-NULL.
for (int i = 0; i < count; ++i) {
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
Debug::Log::Printf(
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
return;
}
}
constexpr int kMargin = 16;
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
const int newCount = count + deficit + kMargin;
auto *newBuf = static_cast<uint8_t *>(
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
if (newBuf == nullptr)
return; // alloc failed — keep the old table; claiming still works
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
// per-entry init: copy the name pointer (shared string, not duplicated);
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
// the anchor as an empty self-sentinel at the new location. Old entries
// past the copy and all new entries are already zero-filled + get fresh
// anchors. Safe because every chain is empty.
for (int i = 0; i < newCount; ++i) {
auto *e = reinterpret_cast<uint32_t *>(
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
if (i < count) // copy name; new slots stay null
e[0] = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE);
const uint32_t anchor = static_cast<uint32_t>(
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
e[2] = anchor; // self-referential list anchor
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
}
// Publish base BEFORE count so the globals never advertise more entries
// than the buffer holds. Then free the OLD buffer only — the name
// strings live on in newBuf and are freed by the reload teardown.
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
count, newCount, nulls, g_reservedCount,
force ? ", FORCED" : "");
}
} // namespace
AutoReserve::AutoReserve(const char *name) {
@@ -133,6 +283,11 @@ int LookupByName(const char *name) {
}
void RetryClaims() {
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
// time to move the buffer.
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
@@ -145,6 +300,8 @@ void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = -1;
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
// Diagnostic Lua functions — registered via the auto-register pattern
@@ -190,10 +347,24 @@ int __fastcall Script_DumpAllEvents(void *) {
return 0;
}
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
// exercised: call it, then `/reload`. The grow fires at the first
// `RegisterEvent` after the rebuild (chains still empty) and logs
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
int __fastcall Script_ForceEventGrow(void *) {
g_forceGrowNext = true;
Debug::Log::Printf(
"[event] force-grow armed — /reload to trigger it on the next build");
return 0;
}
void RegisterDiagnostics() {
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
&Script_DumpAllEvents);
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
&Script_ForceEventGrow);
}
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
+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[] = {
+18
View File
@@ -282,7 +282,25 @@ void RegisterLuaFunctions() {
&Script_C_FriendList_IsWhoQueryPending);
}
// Clear transient /who state on every in-game Lua (re)build — login OR
// /reload. The DLL survives a logout→character-select→login, so a query
// whose SMSG_WHO response never crossed that boundary would otherwise leave
// g_inFlight stuck (IsWhoQueryPending() wrongly true for the next character)
// and our VAR_WHO_TO_UI_FLAG override forced on (routing the next
// character's unrelated /who to the WhoList). ResetIfStuck only heals this
// lazily on a *subsequent* send; a fresh-init reset makes every character
// start clean. RestoreFlagIfOwned puts the engine flag back to the value we
// snapshotted before overriding. ModuleAutoRegister fires per login and per
// /reload, before any addon runs, so this always precedes a new burst.
void ResetState() {
g_inFlight = 0;
RestoreFlagIfOwned();
g_haveSent = false;
g_lastSendTick = 0;
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ModuleAutoRegister _resetOnInit{&ResetState};
} // namespace
+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) {
+96 -35
View File
@@ -30,17 +30,23 @@
// (dispatcher leaves `_G.arg<N>` alone when no codes are parsed),
// restore.
// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
// `"nameplateN"` unit token (formatted from the plate's index in
// `g_orderedGUIDs` at fire time). The token resolves to the unit
// via the `nameplateN`-aware token resolver in `unit/TokenExtensions.cpp`
// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
// or to `GetNamePlateForUnit` for the frame.
// `"nameplateN"` unit token (`N` = the plate's assigned slot). The token
// resolves to the unit via the `nameplateN`-aware token resolver in
// `unit/TokenExtensions.cpp` — addons can pass it straight to `UnitName`,
// `UnitGUID`, etc., or to `GetNamePlateForUnit` for the frame.
//
// Slot assignment (retail-exact): a plate keeps its slot for its whole
// lifetime, so surviving plates are NEVER renumbered when another is removed
// — a removal frees the slot and the next new plate reuses the lowest free
// one. `g_slots` is therefore a sparse array (freed middle slots read back as
// the empty GUID 0 until reused), not a compacting list.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "nameplate/Walk.h"
#include "tick/WorldTick.h"
#include "unit/TokenObserver.h"
#include <algorithm>
#include <cstdint>
@@ -84,13 +90,27 @@ std::unordered_map<uint64_t, const void *> g_currentTickPlates;
// then tops out as pool reuse covers all subsequent shows.
std::unordered_set<const void *> g_seenPlates;
// Ordered list of currently-visible nameplate GUIDs, in
// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
// the `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`. Order
// matches modern WoW semantics: stable for the lifetime of each
// plate, gaps when middle plates vanish (until the next REMOVED
// shifts later entries down).
std::vector<uint64_t> g_orderedGUIDs;
// Nameplate token slots: slot `i` (0-based) holds the GUID assigned to
// `nameplate(i+1)`, or 0 when free. Retail-exact slot assignment — a plate
// keeps its slot for its whole lifetime (survivors are never renumbered),
// UNIT_REMOVED frees the slot (sets 0), and a new plate reuses the lowest
// free slot. Sparse: freed middle slots stay as gaps until reused. Backs the
// `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`.
std::vector<uint64_t> g_slots;
// Assign `guid` the lowest free slot, reusing a vacated one when present and
// growing the array only when every slot is occupied. Returns the 0-based
// slot. Never shifts an existing entry.
int AssignSlot(uint64_t guid) {
for (size_t i = 0; i < g_slots.size(); ++i) {
if (g_slots[i] == 0) {
g_slots[i] = guid;
return static_cast<int>(i);
}
}
g_slots.push_back(guid);
return static_cast<int>(g_slots.size() - 1);
}
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
@@ -114,6 +134,32 @@ const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
return buf;
}
// Descriptor-field observer callback — makes unit events (UNIT_HEALTH,
// UNIT_AURA, …) fire with the `"nameplateN"` token, so a nameplated unit is a
// first-class unit-event source exactly like a party member. Registered per
// nameplate GUID via `Unit::TokenObserver` on UNIT_ADDED, unregistered on
// UNIT_REMOVED. We reverse-look-up the changed GUID's slot at fire time
// (cheap; the array is small) so the token reflects its current assignment.
// The event id is the field index (`fieldOffset >> 2`); the engine fires every
// unit event as "%s" + token, so we match. A GUID no longer in any slot
// (removal raced this field change) simply drops the fire.
int __fastcall NamePlateFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
const uint64_t guid =
(static_cast<uint64_t>(guidHi) << 32) | static_cast<uint64_t>(guidLo);
auto it = std::find(g_slots.begin(), g_slots.end(), guid);
if (it == g_slots.end())
return 1;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased);
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", tokenBuf);
return 1;
}
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
// types, but it only mutates `_G.arg<N>` for codes it actually
@@ -178,38 +224,42 @@ void OnWorldTick() {
// Fire CREATED (with the Frame as arg1) for never-before-seen
// frame pointers; ADDED (with `"nameplateN"` token as arg1) for
// GUIDs not in last tick's snapshot. New entries are appended to
// the ordered list *before* firing so the token resolves to the
// newly-added plate during the event handler.
// GUIDs not in last tick's snapshot. The slot is assigned *before*
// firing so the token resolves to the newly-added plate during the
// event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
g_orderedGUIDs.push_back(kv.first);
const int slot = AssignSlot(kv.first);
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
// its "nameplateN" token (the engine only watches its own
// target/party/raid tokens, not nameplates).
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
char tokenBuf[24];
FireWithString(kEventUnitAdded,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
static_cast<int>(g_orderedGUIDs.size())));
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
}
}
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
// We compute the token from the position *before* erasing so the
// event payload reflects the slot the unit just vacated; the
// handler can still resolve the token to the unit via
// `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
// when we erase — matches modern semantics.
// Fire REMOVED for GUIDs in last tick's snapshot but not current. The
// slot is *freed* (set 0), not erased, so surviving plates keep their
// index (retail-exact — no shift); the next ADD reuses it. Token is
// computed before freeing so the payload names the slot just vacated.
// Trailing empties are trimmed so the array tracks the high-water mark.
for (const auto &kv : g_lastTickPlates) {
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
kv.first);
if (it == g_orderedGUIDs.end())
auto it = std::find(g_slots.begin(), g_slots.end(), kv.first);
if (it == g_slots.end())
continue;
const int oneBased = static_cast<int>(it - g_orderedGUIDs.begin()) + 1;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FireWithString(kEventUnitRemoved,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
g_orderedGUIDs.erase(it);
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
*it = 0; // free the slot (no shift of survivors)
while (!g_slots.empty() && g_slots.back() == 0)
g_slots.pop_back();
}
}
@@ -230,24 +280,35 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// suppress every refire, and the freshly-built wrapper would lack
// the addon's `.nameplate` field.
//
// `g_orderedGUIDs` is also cleared so the post-reload token indices
// start at `nameplate1` again, matching the order plates re-fire in.
// `g_slots` is also cleared so post-reload token slots start fresh.
void PrepareForReload() {
g_seenPlates.clear();
g_lastTickPlates.clear();
g_orderedGUIDs.clear();
// Objects survive a /reload, so their observer nodes do too. The first
// post-reload tick re-fires ADDED and re-registers, which would stack a
// second observer per field (the registrar never dedups) → double events.
// Tear ours down here so re-registration starts clean (skip free slots).
for (uint64_t guid : g_slots)
if (guid != 0)
Unit::TokenObserver::Unregister(guid, &NamePlateFieldCb);
g_slots.clear();
}
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
// the internal vector.
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
uint64_t GetGUIDByIndex(int oneBased) {
if (oneBased <= 0)
return 0;
const size_t idx = static_cast<size_t>(oneBased - 1);
if (idx >= g_orderedGUIDs.size())
if (idx >= g_slots.size())
return 0;
return g_orderedGUIDs[idx];
return g_slots[idx]; // 0 when the slot is free
}
// Number of slots (1-based max index). Because the slot array is SPARSE,
// callers that iterate all plates must scan `1..GetSlotCount()` and skip the
// slots where `GetGUIDByIndex` returns 0 — they can't stop at the first gap.
int GetSlotCount() { return static_cast<int>(g_slots.size()); }
} // namespace NamePlate::Events
+10 -4
View File
@@ -37,12 +37,18 @@ void PushNamePlateFrame(void *L, void *nameplate);
namespace NamePlate::Events {
// Returns the GUID currently bound to `nameplateN` (1-based, matching
// the modern Lua token form). `0` means no nameplate at that index.
// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
// `Events.cpp` — order is creation-order (append on ADDED, erase on
// REMOVED), stable within a frame.
// the modern Lua token form). `0` means the slot is out of range OR
// currently free. Backed by the retail-exact slot assignment in
// `Events.cpp`: a plate keeps its slot for its lifetime and freed slots
// are reused, so the slot array is SPARSE (gaps read back as 0).
uint64_t GetGUIDByIndex(int oneBased);
// Highest slot index in use (1-based). Callers that iterate every plate
// must loop `1..GetSlotCount()` and skip indices where `GetGUIDByIndex`
// returns 0 — they must NOT stop at the first gap, since the array is
// sparse.
int GetSlotCount();
// Clears the per-tick diff state (seen pointers, last tick's
// snapshot, ordered GUID list) so the post-`/reload` tick refires
// CREATED + UNIT_ADDED for every currently-visible plate. Called
+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;
+46 -4
View File
@@ -91,6 +91,7 @@ using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, in
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
constexpr int OFF_ATTRIBUTES_EX2 = 0x24; // u32 AttributesEx2 flags
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
@@ -99,6 +100,12 @@ constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// there, +0x18 here — the bit value is the same across builds).
constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
// SPELL_ATTR_RANGED — every ranged ability carries it; the server adds a
// flat +500ms "aim time" to these (SpellEntry::GetCastTime). Auto-repeat
// shots (Auto Shot / Shoot, AttributesEx2 bit 0x20) are the one exclusion.
constexpr uint32_t SPELL_ATTR_RANGED = 0x2;
constexpr uint32_t SPELL_ATTR_EX2_AUTOREPEAT = 0x20;
struct TrackedSpell {
int spellID; // 0 = not casting / channeling
int startMs;
@@ -163,8 +170,30 @@ void *Resolve(const char *token) {
// matches the engine's cast bar exactly — including talents like a Mage's
// faster casts. Unit arg 0 = local player; flag 0 clamps negatives to 0.
int CastTimeMs(int spellID) {
return static_cast<int>(
int ms = static_cast<int>(
reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME)(spellID, 0, 0));
// FUN_006e3340 (also the spell tooltip's cast-time source) does NOT add the
// flat +500ms "aim time" the server applies to ranged, non-autorepeat
// spells — SpellEntry::GetCastTime does `castTime += 500` for
// SPELL_ATTR_RANGED. So our zero-latency prediction runs ~500ms short of
// the server for Volley / Aimed Shot / Multi-Shot; the confirming
// SMSG_SPELL_START then snaps the bar out ~1 RTT later. Mirror the server
// here so the prediction is right from frame 1 (only for spells that
// already have a cast time — instant ranged abilities that the server
// gives a 500ms aim time still surface through the packet path). The
// cast-time reconcile in HandleSpellStart remains the backstop.
if (ms > 0) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec != nullptr) {
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES);
const uint32_t attrEx2 =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX2);
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & SPELL_ATTR_EX2_AUTOREPEAT))
ms += 500;
}
}
return ms;
}
// Effective channel duration (ms) for the local player, 0 if none. Uses
@@ -407,13 +436,26 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
// client path (FUN_CAST_START_SET hook) never fires for them — this
// server packet is the only signal. Dedup against a fresh client stamp
// (same spell, started within ~latency) so a normal cast's confirming
// packet doesn't restart its bar; otherwise it's a chained recast the
// (same spell, started within ~latency): a normal cast's confirming
// packet mustn't RESTART its bar; otherwise it's a chained recast the
// client bailed on — take it, and flag it so the VAR==0 poll (which
// never saw it) won't clear it.
if (g_cast.spellID == spellID && now < g_cast.endMs &&
now - g_cast.startMs < kCastStartDedupMs)
now - g_cast.startMs < kCastStartDedupMs) {
// Confirming packet. Keep startMs (no visual restart), but SNAP the
// end time to the server's authoritative castTime. The client stamp
// came from FUN_006e3340, which folds in the caster's cast-speed /
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
// Volley is the case that surfaced this: not channeled, not sped by
// Quick Shots server-side, so the hasted prediction ended the bar
// early and the player was "still casting after the bar finished."
// The server's castTime is reality; reconcile to it (preserving any
// pushback already accumulated). A no-op when client and server agree.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
return;
}
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
g_castFromServer = true;
g_channel.spellID = 0; // a cast supersedes any channel
+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);
+66
View File
@@ -55,6 +55,71 @@ static int __fastcall Script_GameTooltipSetSpellByID(void *L) {
return 0;
}
// `GameTooltip:AddSpellByID(spellID)` — APPENDS a spell's tooltip to the
// current one instead of clearing+rebuilding (the `SetSpellByID` behavior).
// The natural pair to `SetSpellByID`; lets callers compose tooltips like
// `AddLine(talentName) + AddSpellByID(rankSpellID)`.
//
// `BuildSpellTooltip`'s last arg (`param_7`) gates the clear: `0` calls the
// per-tooltip Clear (`FUN_00530050`) first — that's `SetSpellByID`; non-zero
// SKIPS the clear and appends. The append path is the engine's talent
// "next rank" preview, so it (a) emits a `"\nNext rank:"` header line
// instead of the spell name, and (b) does NOT stash the displayed spellID at
// `+0x39C` (so `GetSpell` keeps reflecting the base tooltip — correct for an
// append). We fix (a) by overwriting that header line — which lands at index
// `numLines`-before-the-build (0-based) — with the real spell name via the
// line pool's left-text FontString. Verified against `FUN_0052E610`.
void AppendByID(void *L, int spellID) {
if (spellID <= 0)
return;
void *tooltipObj = Game::Lua::ResolveObject(L, 1);
if (tooltipObj == nullptr)
return;
auto *tt = static_cast<uint8_t *>(tooltipObj);
const int before = *reinterpret_cast<const int *>(
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
auto BuildSpellTooltip =
reinterpret_cast<BuildSpellTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, 0, 0, /*param_7 (append)=*/1);
const int after = *reinterpret_cast<const int *>(
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
if (after <= before)
return; // nothing appended (bad spellID / missing Spell.dbc record)
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return;
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_NAME + locale * 4);
if (name == nullptr || name[0] == '\0')
return;
// Left-text FontString array: descriptor at +0x324, data ptr at +0x8.
auto **textLeft = *reinterpret_cast<void ***>(
tt + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 8);
if (textLeft == nullptr)
return;
void *fs = textLeft[before];
if (fs == nullptr)
return;
using SetText_t = void(__thiscall *)(void *fs, const char *text, int flag);
reinterpret_cast<SetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(fs, name, 0);
}
static int __fastcall Script_GameTooltipAddSpellByID(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GameTooltip:AddSpellByID(spellID)");
return 0;
}
AppendByID(L, static_cast<int>(Game::Lua::ToNumber(L, 2)));
return 0;
}
// `GameTooltip:GetSpell()` → (name, rank, spellID) for whichever spell
// the tooltip is currently displaying, or nothing if it isn't showing
// a spell. BuildSpellTooltip writes the spellID to `tooltip+0x39C`;
@@ -126,6 +191,7 @@ static int __fastcall Script_GameTooltipHasSpell(void *L) {
static const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetSpellByID", &Script_GameTooltipSetSpellByID},
{"AddSpellByID", &Script_GameTooltipAddSpellByID},
{"GetSpell", &Script_GameTooltipGetSpell},
{"HasSpell", &Script_GameTooltipHasSpell},
};
+9
View File
@@ -26,4 +26,13 @@ namespace Spell::Tooltip {
// so we render the rank-1 spell tooltip instead).
void ShowByID(void *L, int spellID);
// Like `ShowByID` but APPENDS the spell's tooltip to the current one
// (no clear), and overwrites the engine's talent "next rank" header with
// the real spell name. Backs `GameTooltip:AddSpellByID`. Silent no-op if
// `spellID <= 0`, stack[1] isn't a frame object, or the build added no
// lines. Does not stash the displayed spellID (so `GetSpell` still
// reflects the base tooltip). Useful for cross-class talent tooltips
// (`AddLine(talentName) + AppendByID(rankSpellID)`).
void AppendByID(void *L, int spellID);
} // namespace Spell::Tooltip
+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)
+76
View File
@@ -366,6 +366,80 @@ int __fastcall Script_GetAllTaxiNodes(void *L) {
return 1;
}
// Direct-edge test — mirrors FUN_004dbad0: a 256x256 [from][to] matrix of
// TaxiPath IDs (0 = no direct flight path). IDs outside 1..256 are never direct.
bool DirectEdge(int from, int to) {
if (from <= 0 || from > Offsets::TAXI_ADJACENCY_DIM ||
to <= 0 || to > Offsets::TAXI_ADJACENCY_DIM)
return false;
const int *adj = reinterpret_cast<const int *>(Offsets::VAR_TAXI_ADJACENCY);
return adj[from * Offsets::TAXI_ADJACENCY_DIM + to] != 0;
}
// `C_TaxiMap.GetTaxiRoute(slotIndex)` — the TaxiNodes.dbc ID chain the client
// would fly to the taxi-map slot `slotIndex` (1..NumTaxiNodes), in flight
// order { currentNodeID, ..., destNodeID }. This mirrors TakeTaxiNode's own
// decision (FUN_004dc8d0): a direct hop when a TaxiPath edge exists, otherwise
// the client's precomputed multi-hop route array. It is the exact node list
// TakeTaxiNode would hand the server, so an addon can identify every hop
// without matching flight-map coordinates back to nodes. Returns nothing when
// no taxi map is open, the slot is out of range, it is the current node, or no
// route exists.
int __fastcall Script_GetTaxiRoute(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int slot1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
if (count <= 0 || slot1 < 1 || slot1 > count)
return 0;
const auto *arrayBase =
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
const uint8_t *slotRec =
arrayBase + (slot1 - 1) * Offsets::TAXI_NODE_STRIDE;
const uint8_t *curRec = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_TAXI_CURRENT_REC);
const uint8_t *dstRec = *reinterpret_cast<const uint8_t *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
if (curRec == nullptr || dstRec == nullptr)
return 0;
const int currentID = IntField(curRec, 0);
const int destID = IntField(dstRec, 0);
if (currentID == 0 || destID == 0 || currentID == destID)
return 0;
// Collect the chain exactly as FUN_004dc8d0 (TakeTaxiNode) would send it.
int ids[64];
int n = 0;
if (DirectEdge(currentID, destID)) {
ids[n++] = currentID;
ids[n++] = destID;
} else if (*(slotRec + Offsets::OFF_TAXI_SLOT_MULTIHOP) != 0) {
const int *route = *reinterpret_cast<const int *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE);
const int rc = *reinterpret_cast<const int *>(
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE_COUNT);
if (route == nullptr || rc < 2)
return 0;
const int cap = rc < 64 ? rc : 64;
for (int i = 0; i < cap; ++i)
ids[n++] = route[i];
} else {
return 0; // reachable node with no computed route (shouldn't happen)
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (int i = 0; i < n; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(ids[i]));
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
static const Game::Lua::EnumIntegerEntry kFaction[] = {
{"Neutral", FACTION_NEUTRAL},
@@ -385,6 +459,8 @@ void RegisterLuaFunctions() {
&Script_GetTaxiNodesForMap);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
&Script_GetAllTaxiNodes);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiRoute",
&Script_GetTaxiRoute);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+139
View File
@@ -0,0 +1,139 @@
// 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_TaxiMap.GetTaxiPaths()` and `C_TaxiMap.GetTaxiPathWaypoints(pathID)` —
// direct readers for `TaxiPath.dbc` (the directed flight-path edge list with
// costs) and `TaxiPathNode.dbc` (each path's world-coordinate waypoint
// polyline). Vanilla exposes neither; addons can't read DBCs at all, so this
// hands them the raw taxi graph + geometry.
//
// Together they let an addon build the taxi routing graph (nodes + weighted
// directed edges) and measure real flight distances (sum the 3D chords of a
// path's waypoints). Uses for a flight-path database, a map overlay, or a
// travel-time estimator — see the sibling `C_TaxiMap.GetTaxiNodesForMap` for
// the node table these edges connect.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <algorithm>
#include <cstdint>
#include <vector>
namespace Taxi::Paths {
namespace {
int IntField(const uint8_t *rec, int off) {
return *reinterpret_cast<const int *>(rec + off);
}
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// `C_TaxiMap.GetTaxiPaths()` — every row of TaxiPath.dbc as
// { pathID, fromNodeID, toNodeID, cost }. Directed: A->B and B->A are two
// separate entries.
int __fastcall Script_GetTaxiPaths(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
Offsets::VAR_TAXIPATH_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "pathID", id);
Game::Lua::SetFieldNumber(L, "fromNodeID", IntField(rec, Offsets::OFF_TAXIPATH_FROM));
Game::Lua::SetFieldNumber(L, "toNodeID", IntField(rec, Offsets::OFF_TAXIPATH_TO));
Game::Lua::SetFieldNumber(L, "cost", IntField(rec, Offsets::OFF_TAXIPATH_COST));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_TaxiMap.GetTaxiPathWaypoints(pathID)` — the path's waypoints as
// { x, y, z, mapID }, ordered by node index (flight order). Returns nothing
// for a missing / unknown pathID or an empty path.
int __fastcall Script_GetTaxiPathWaypoints(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int wantPath = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (wantPath <= 0)
return 0;
struct Waypoint {
int index;
float x, y, z;
int mapID;
};
std::vector<Waypoint> pts;
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATHNODE_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATHNODE_RECORDS,
Offsets::VAR_TAXIPATHNODE_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (IntField(rec, Offsets::OFF_TAXIPATHNODE_PATH_ID) != wantPath)
continue;
pts.push_back({IntField(rec, Offsets::OFF_TAXIPATHNODE_INDEX),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_X),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Y),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Z),
IntField(rec, Offsets::OFF_TAXIPATHNODE_MAP)});
}
if (pts.empty())
return 0;
// DBC rows for a path are stored in index order, but sort to guarantee a
// dense 1-based (ipairs-safe) array in flight order regardless.
std::sort(pts.begin(), pts.end(),
[](const Waypoint &a, const Waypoint &b) { return a.index < b.index; });
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int outIdx = 0;
for (const Waypoint &p : pts) {
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "x", p.x);
Game::Lua::SetFieldNumber(L, "y", p.y);
Game::Lua::SetFieldNumber(L, "z", p.z);
Game::Lua::SetFieldNumber(L, "mapID", p.mapID);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPaths",
&Script_GetTaxiPaths);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPathWaypoints",
&Script_GetTaxiPathWaypoints);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Taxi::Paths
+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
+111
View File
@@ -47,6 +47,7 @@
#include "item/Count.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -499,6 +500,108 @@ int __fastcall Script_TargetTotem(void *L) {
return 0;
}
// ---- GameTooltip:SetTotem(slot) -------------------------------------------
//
// Built with the engine's own tooltip primitives — the per-tooltip clear
// and the raw add-line — exactly as retail's `Script_GameTooltip_SetTotem`
// does (no Lua method dispatch). Colors are the packed `{b,g,r,a}` values
// `FUN_GAMETOOLTIP_ADD_LINE` wants, pulled straight from `UI::ColorData`.
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Retail's two line colors, read verbatim from the 3.3.5 binary's SetTotem
// color data: name = NORMAL_FONT_COLOR (0xFFFFD200), time = white. Both are
// already in the 0xAARRGGBB packing the line-color setter consumes.
constexpr uint32_t kNameColor = UI::ColorData::ByTag("NORMAL_FONT_COLOR");
constexpr uint32_t kTimeColor = UI::ColorData::ByTag("HIGHLIGHT_FONT_COLOR");
// Left-aligned colored line via the engine's raw add-line. `color` must
// outlive the call (its address is passed through).
void AddColoredLine(void *self, const char *text, const uint32_t &color) {
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// `GameTooltip:SetTotem(slot)` — a TBC (2.4.0) tooltip method backported to
// 1.12. Fills the tooltip with the slot's active totem, mirroring retail's
// two-line shape (3.3.5 `Script_GameTooltip_SetTotem` at 0x006205c0):
//
// <Totem Name> NORMAL_FONT_COLOR (yellow, 0xFFFFD200)
// <Time Left> white — SecondsToTimeAbbrev(SPELL_TIME_REMAINING)
//
// The time line mirrors retail's `SecondsToTimeAbbrev` with its round-up
// flag: raw seconds under a minute, minutes rounded up above. The unit
// wording is localized through the project's GlobalString helper (retail's
// `SPELL_TIME_REMAINING_SEC`/`_MIN` keys, English fallback). No-op (tooltip
// left untouched) when the slot has no active totem — same as retail's
// `+8/+0xc` timing guard. Shows itself at the end (via the engine's
// Script_Show) — retail's TotemFrame.xml OnEnter calls SetTotem with no
// trailing :Show(), so the method is expected to show.
int __fastcall Script_GameTooltipSetTotem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GameTooltip:SetTotem(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (slot < 1 || slot > kSlots)
return 0;
const Slot &t = g_slots[slot - 1];
if (!t.active || t.spellID == 0)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(t.spellID));
if (rec == nullptr)
return 0;
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
if (name == nullptr || name[0] == '\0')
return 0;
// Milliseconds remaining (0 = unknown / expired duration).
uint32_t msLeft = 0;
if (t.durationMs != 0) {
const uint32_t now = NowMs();
const uint32_t end = t.startMs + t.durationMs;
if (now < end)
msLeft = end - now;
}
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, name, kNameColor);
if (msLeft > 0) {
const int savedTop = Game::Lua::GetTop(L);
if (msLeft < 60000) {
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_SEC",
"%d Sec",
static_cast<int>(msLeft / 1000));
} else {
const int minutes = static_cast<int>((msLeft + 59999) / 60000); // ceil
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_MIN",
"%d Min", minutes);
}
const char *timeText = Game::Lua::ToString(L, -1);
if (timeText != nullptr)
AddColoredLine(self, timeText, kTimeColor);
Game::Lua::SetTop(L, savedTop);
}
// Show ourselves — self is still at Lua stack index 1, which is what
// Script_Show reads.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemInfo", &Script_GetTotemInfo);
Game::Lua::RegisterGlobalFunction("GetTotemTimeLeft",
@@ -506,6 +609,14 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemDuration",
&Script_GetTotemDuration);
Game::Lua::RegisterGlobalFunction("TargetTotem", &Script_TargetTotem);
static const Game::Lua::FrameMethodEntry kTooltipMethods[] = {
{"SetTotem", &Script_GameTooltipSetTotem},
};
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
kTooltipMethods,
static_cast<int>(sizeof(kTooltipMethods) / sizeof(kTooltipMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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);
+183 -13
View File
@@ -33,6 +33,17 @@
// every world tick — when it returns null, we `Set(0)` which fires
// the event. Cost is one hash-table lookup per tick while focus is
// set; zero when no focus is active.
//
// Unit events for `focus` / `focustarget`: `Unit::TokenObserver` watches
// the focus unit's descriptor fields so `UNIT_HEALTH` / `UNIT_AURA` / …
// fire with `arg1 == "focus"` (see FocusFieldCb). `focustarget` gets the
// same treatment via a second observer — but its GUID is the focus unit's
// *current target*, which changes with no event to signal it (vanilla's
// `UNIT_FIELD_TARGET`, field 10, has no event-table slot). So the WorldTick
// watcher re-reads the focus's target each tick and re-points the
// focustarget observer only when that identity changes; the focustarget
// *events* still fire event-driven through the observer — only the
// re-pointing is tick-gated (≤1 tick lag after the focus swaps targets).
#include "Focus.h"
@@ -40,6 +51,7 @@
#include "Offsets.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include "unit/TokenObserver.h"
#include <cstdint>
@@ -52,6 +64,49 @@ 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
// focus unit via `Unit::TokenObserver` (mirrors the engine's own watch of
// target/party/raid units). The event id is the field index (`fieldOffset >>
// 2`); the engine fires every unit event with format "%s" + the token, so we
// match that. Guarded on the live focus GUID so an in-flight field change that
// races an unregister can't fire a stale token.
int __fastcall FocusFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
if (guidLo != static_cast<uint32_t>(g_focusGUID) ||
guidHi != static_cast<uint32_t>(g_focusGUID >> 32))
return 1;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", "focus");
return 1;
}
// GUID currently watched as `focustarget` (the focus unit's target), or 0.
// Re-pointed by `OnWorldTick`; distinct from `g_focusGUID`.
uint64_t g_focusTargetGUID = 0;
// Same as `FocusFieldCb` but fires the `"focustarget"` token.
int __fastcall FocusTargetFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
if (guidLo != static_cast<uint32_t>(g_focusTargetGUID) ||
guidHi != static_cast<uint32_t>(g_focusTargetGUID >> 32))
return 1;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", "focustarget");
return 1;
}
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
@@ -65,21 +120,96 @@ 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.
void OnWorldTick() {
if (g_focusGUID == 0)
return;
// 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));
if (resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
static_cast<uint32_t>(g_focusGUID),
static_cast<uint32_t>(g_focusGUID >> 32),
0x172) == nullptr)
Set(0);
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() {
uint64_t desiredFocusTarget = 0;
if (g_focusGUID != 0) {
const uint8_t *focusObj = ResolveObject(g_focusGUID);
if (focusObj == nullptr) {
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)
desiredFocusTarget = *reinterpret_cast<const uint64_t *>(
fields + Offsets::OFF_UNIT_FIELD_TARGET);
}
}
// Re-point the focustarget observer only when the target identity
// changes (focus swapped targets, focus cleared, or focus has no target).
if (desiredFocusTarget != g_focusTargetGUID) {
if (g_focusTargetGUID != 0)
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
g_focusTargetGUID = desiredFocusTarget;
if (g_focusTargetGUID != 0)
TokenObserver::Register(g_focusTargetGUID, &FocusTargetFieldCb);
}
}
// `FocusUnit(unit)` — sets focus to whatever GUID `unit` currently
@@ -111,6 +241,25 @@ void RegisterLuaFunctions() {
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Drop focus when the player logs out to the glue screen. The DLL isn't
// reloaded on a logout→character-select→login, so g_focusGUID would
// otherwise carry a dead GUID from character A into character B. The
// WorldTick despawn probe self-heals it within a tick (ObjectByGUID miss
// → Set(0)), but that fires a spurious PLAYER_FOCUS_CHANGED on B's first
// tick and can't cover the (astronomically rare) GUID-collision case, so
// clearing here is both cleaner and exact retail parity. We use the glue
// hook, NOT FrameScript_Initialize, because it fires on the world→glue
// return a logout triggers but NOT on /reload — so focus correctly
// survives /reload (retail behavior) and drops only on logout. No event /
// observer work: char A's focus observers already died with the object
// during world teardown, and B starts with focus nil (no event expected).
void ClearOnLogout() {
g_focusGUID = 0;
g_focusTargetGUID = 0;
g_focusObserved = false;
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
} // namespace
uint64_t Get() { return g_focusGUID; }
@@ -118,7 +267,28 @@ uint64_t Get() { return g_focusGUID; }
void Set(uint64_t guid) {
if (guid == g_focusGUID)
return; // no-op: same target → no event fire (matches 3.3.5)
// Stop watching the old focus, start watching the new one, so unit events
// (UNIT_HEALTH, …) fire with "focus" for whatever is focused — even a unit
// the engine isn't otherwise watching. Unregister is a safe no-op if the
// 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) {
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
g_focusTargetGUID = 0;
}
g_focusGUID = guid;
// 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, "");
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// `UnitHealthMissing(unit)` — the health deficit, i.e. the equivalent of
// `UnitHealthMax(unit) - UnitHealth(unit)`. A convenience for healing addons
// (overheal checks, "missing health" bars) that otherwise call both engine
// functions and subtract in Lua every frame. Reads the two health fields off
// the unit's descriptor directly (same path `Unit::Power` uses for POWER),
// so it's a single resolve + two field reads.
//
// Health carries no display divisor — unlike rage/happiness, HEALTH and
// MAXHEALTH are stored at face value — so the deficit is a plain subtraction,
// clamped at 0 (a transient current > max should never surface as negative).
#include "Game.h"
#include "Offsets.h"
#include "group/MemberStats.h"
#include <cstdint>
namespace Unit::Health {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
}
// `UnitHealthMissing("unit")` — returns `max - current` health (never
// negative). Returns 0 for a valid-but-absent unit (e.g. `"target"` with no
// target), matching `UnitHealth`'s 0-for-missing convention.
static int __fastcall Script_UnitHealthMissing(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitHealthMissing(\"unit\")");
return 0;
}
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
if (desc == nullptr) {
// 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 *>(
desc + Offsets::OFF_UNIT_FIELD_HEALTH);
const uint32_t max = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXHEALTH);
Game::Lua::PushNumber(L, static_cast<double>(max > cur ? max - cur : 0u));
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitHealthMissing", &Script_UnitHealthMissing);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::Health
+26 -10
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).
//
@@ -289,16 +307,14 @@ const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
if (GuidForToken(buf) == target) return buf;
}
// Visible nameplates. The ordered list (see
// `NamePlate::Events::g_orderedGUIDs`) is dense — `GetGUIDByIndex`
// returns 0 once we step past the populated count, terminating
// the loop. Skips the token-resolver hook + SStrCmpI cost per
// index by comparing GUIDs directly.
for (int i = 1; ; ++i) {
const uint64_t plateGuid = NamePlate::Events::GetGUIDByIndex(i);
if (plateGuid == 0)
break;
if (plateGuid == target) {
// Visible nameplates. The slot array is SPARSE (retail-exact slot
// assignment — freed middle slots stay as gaps until reused), so we
// scan `1..GetSlotCount()` and skip empties rather than stopping at
// the first 0. Compares GUIDs directly, skipping the token-resolver
// hook + SStrCmpI cost per index.
const int plateCount = NamePlate::Events::GetSlotCount();
for (int i = 1; i <= plateCount; ++i) {
if (NamePlate::Events::GetGUIDByIndex(i) == target) {
std::snprintf(buf, bufSize, "nameplate%d", i);
return buf;
}
+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
+93 -9
View File
@@ -8,9 +8,10 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// Modern power-API polyfill: `UnitPower(unit [, type])`,
// `UnitPowerMax(unit [, type])`, and an extended `UnitPowerType(unit)`
// that returns the modern 2-tuple `(powerType, powerToken)`.
// Modern power-API polyfill: `UnitPower(unit [, type [, unmodified]])`,
// `UnitPowerMax(unit [, type [, unmodified]])`, `UnitPowerMissing(unit
// [, type [, unmodified]])`, and an extended `UnitPowerType(unit)` that
// returns the modern 2-tuple `(powerType, powerToken)`.
//
// Shape modeled on 3.3.5's `Script_UnitPower` (`0x0060ED40` in the
// Frostmourne client) and `Script_UnitPowerMax` (`0x0060EF40`). The
@@ -33,6 +34,7 @@
#include "Game.h"
#include "Offsets.h"
#include "group/MemberStats.h"
#include "unit/Power.h"
#include <cstdint>
@@ -139,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.
@@ -146,18 +191,21 @@ bool ResolveArgs(void *L, const uint8_t **outDesc, int *outType) {
// vanilla).
static int __fastcall Script_UnitPower(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type])");
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type [, unmodified]])");
return 0;
}
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 *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
const uint32_t divisor = PowerDivisor(type);
// `unmodified` (arg 3, truthy) returns the raw internal value; otherwise
// apply the per-type display divisor. Matches retail's third UnitPower arg.
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
@@ -167,22 +215,57 @@ static int __fastcall Script_UnitPower(void *L) {
// `UnitPower`.
static int __fastcall Script_UnitPowerMax(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type])");
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type [, unmodified]])");
return 0;
}
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 *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = PowerDivisor(type);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
// `UnitPowerMissing("unit" [, powerType [, unmodified]])` — the power
// deficit, i.e. `UnitPowerMax - UnitPower` for the given type. Same arg
// shape and fallbacks as `UnitPower`; `unmodified` (arg 3, truthy) skips
// the display divisor and returns the raw internal deficit, mirroring
// retail's third `UnitPower` arg.
//
// Computed as the difference of the two DISPLAY values (each integer-divided
// by the per-type divisor), NOT by dividing the raw difference — so it equals
// `UnitPowerMax(...) - UnitPower(...)` exactly. For rage those diverge:
// raw 1000/55 displays as 100/5, giving a deficit of 95, whereas
// `(1000-55)/10` truncates to 94.
static int __fastcall Script_UnitPowerMissing(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(
L, "Usage: UnitPowerMissing(\"unit\" [, powerType [, unmodified]])");
return 0;
}
const uint8_t *desc;
int type;
if (!ResolveArgs(L, &desc, &type)) {
Game::Lua::PushNumber(L, RosterPowerFallback(L, RosterKind::Missing));
return 1;
}
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
const uint32_t max = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
const uint32_t curDisp = cur / divisor;
const uint32_t maxDisp = max / divisor;
Game::Lua::PushNumber(
L, static_cast<double>(maxDisp > curDisp ? maxDisp - curDisp : 0u));
return 1;
}
// `UnitPowerType(unit)` — extended to return `(powerType, powerToken)`.
// Vanilla 1.12's implementation pushes only the integer; we chain
// to the engine's original (`0x00517940`) to preserve its full
@@ -210,6 +293,7 @@ static int __fastcall Script_UnitPowerType(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitPower", &Script_UnitPower);
Game::Lua::RegisterGlobalFunction("UnitPowerMax", &Script_UnitPowerMax);
Game::Lua::RegisterGlobalFunction("UnitPowerMissing", &Script_UnitPowerMissing);
Game::Lua::RegisterGlobalFunction("UnitPowerType", &Script_UnitPowerType);
Game::Lua::RegisterIntegerEnum(
"Enum", "PowerType",
+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);
+92
View File
@@ -0,0 +1,92 @@
// 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/>.
// `UnitSpellHaste(unit)` — backport of the TBC+ spell-haste getter to 1.12,
// which has no native haste API. It reads UNIT_MOD_CAST_SPEED (the cast-time
// multiplier at descriptor +0x22c that the server folds into
// `SpellEntry::GetCastTime` — `castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`)
// and converts it to the modern haste PERCENTAGE:
//
// haste% = (1 / modCastSpeed - 1) * 100
//
// so `1.0` (no modifier) -> `0`, `0.5` (half cast time) -> `100`, and a
// slowing effect like Curse of Tongues (`modCastSpeed > 1.0`) -> a negative
// value. This is the same field nampower surfaces raw as `modCastSpeed`;
// `UnitSpellHaste` is the Blizzard-shaped percentage over it, so addons can
// drop the `GetUnitField("player", "modCastSpeed")` nampower dependency.
// (Consumers that need the raw multiplier can recover it as
// `1 / (1 + UnitSpellHaste(unit) / 100)`.)
//
// Vanilla has very few caster-haste sources, so this reads `0` for most
// casters most of the time — the value is meaningful for Curse of Tongues,
// Nature's Grace, and the handful of other cast-speed effects (and, as the
// cast-time work showed, whatever bleeds into the field for hunter shots).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SpellHaste {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
}
// `UnitSpellHaste("unit")` — spell haste percentage. `0` for an unhasted or
// invalid unit; negative for a slowed one (Curse of Tongues).
static int __fastcall Script_UnitSpellHaste(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSpellHaste(\"unit\")");
return 0;
}
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
if (desc == nullptr) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
const float mult = *reinterpret_cast<const float *>(
desc + Offsets::OFF_UNIT_MOD_CAST_SPEED);
// `mult` is the cast-time multiplier; haste% is its inverse deviation from
// 1.0. Guard a non-positive / uninitialized field against div-by-zero
// (treat it as "no haste").
const double haste = (mult > 0.0f)
? (1.0 / static_cast<double>(mult) - 1.0) * 100.0
: 0.0;
Game::Lua::PushNumber(L, haste);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSpellHaste", &Script_UnitSpellHaste);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SpellHaste
+128
View File
@@ -0,0 +1,128 @@
// 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/>.
// Replicates the engine's per-unit "watch descriptor fields → fire per-token
// unit events" registration (`FUN_0051bbb0` / `FUN_0051bdb0`) so synthetic
// tokens can be made first-class. The engine reaches this from
// `FUN_0060c520`, which recomputes a unit's token-category mask
// (target/party/raid/pet/mouseover) at `unit+0xc90` and, on a 0→nonzero
// transition, registers a bank-UNIT observer on every named event field with
// callback `FUN_0051bd50` → `FUN_00515e50(guid, eventId)` → the GUID→token
// reverse map `FUN_00515c50` → fire the event once per token.
//
// We register the same field set with a caller-supplied callback instead, so
// the caller fires its own token. The field set / sizes are read from the
// static event-name table exactly as the engine does; see the
// FUN_DESC_OBSERVER_REGISTER block in Offsets.h.
//
// Cross-checked against nampower's offsets.hpp: `SendUnitSignal` (the
// broadcast) = `0x00515e50`, `StringParamFormat` ("%s") = `0x0082E280`,
// `SignalEventParam` = `0x00703F50`, `UNIT_HEALTH` = 16 — all consistent with
// the field-index-is-event-id derivation here. (nampower left its
// `GetNamesFromGUID` reverse-map offset as an unfilled TODO; it is
// `FUN_00515c50`.)
#include "unit/TokenObserver.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::TokenObserver {
namespace {
using Register_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1,
void *userArg2);
using Unregister_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi,
const void *callback, void *userArg1);
const char *const *EventNames() {
return reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_NAME_TABLE_STATIC));
}
// Both register and unregister funnel through `FUN_00464890`, which
// dereferences the client/player-rooted observer registry at
// `[VAR_LOCAL_PLAYER_PTR]` (reads its `+0x24` mask). That root is NULL out of
// world — login screen, loading screens, logout teardown — where the engine
// helper null-derefs (crash: `MOV EAX,[EDX+0x24]`, EDX=0). Bail when it isn't
// up: there are no live objects or observer nodes to touch anyway (nodes die
// with their objects during teardown), and in-world callers re-register/clean
// up on the next tick. Unlike `Player::Equipment` (which only ever registers
// from the enter-world hook), our consumers register from Lua / WorldTick,
// which can run mid-transition — hence the guard here.
bool RegistryReady() {
return *reinterpret_cast<void *const volatile *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
}
// Bytes the engine watches for the observer on event index `i` — verbatim
// from `FUN_0051bbb0`'s size switch. The 8-byte entries are the 64-bit GUID
// fields; index 0x29 (`UNIT_FIELD_AURA`) spans the whole 0xD8 aura block.
int SizeForIndex(int i) {
switch (i) {
case 0:
case 2:
case 4:
case 10:
return 8;
case 0x29:
return 0xD8;
case 0x6B:
return 0x30;
case 0xA7:
case 0xAE:
return 0x1C;
default:
return 4;
}
}
} // namespace
void Register(uint64_t guid, FieldCallback cb) {
if (guid == 0 || cb == nullptr || !RegistryReady())
return;
auto reg = reinterpret_cast<Register_t>(Offsets::FUN_DESC_OBSERVER_REGISTER);
const char *const *names = EventNames();
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
if (names[i] == nullptr)
continue; // unnamed slot → not a real unit event
reg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
SizeForIndex(i), reinterpret_cast<const void *>(cb), nullptr, nullptr);
}
}
void Unregister(uint64_t guid, FieldCallback cb) {
if (guid == 0 || cb == nullptr || !RegistryReady())
return;
auto unreg =
reinterpret_cast<Unregister_t>(Offsets::FUN_DESC_OBSERVER_UNREGISTER);
const char *const *names = EventNames();
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
if (names[i] == nullptr)
continue;
unreg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
reinterpret_cast<const void *>(cb), nullptr);
}
}
} // namespace Unit::TokenObserver
+52
View File
@@ -0,0 +1,52 @@
// 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 Unit::TokenObserver {
// Descriptor-field observer callback — the engine's __AUCMirrorHandler__ node
// ABI (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h): return 1,
// callee cleans 0x10. `fieldOffset` is the bank-relative offset as registered;
// the corresponding unit event id is `fieldOffset >> 2` (the descriptor field
// index equals the unit event id — health field +0x40 → event 16 =
// `UNIT_HEALTH`).
using FieldCallback = int(__fastcall *)(uint32_t fieldOffset, uint32_t size,
uint32_t guidLo, uint32_t guidHi,
const uint32_t *oldValue, void *userArg);
// Register / unregister the engine's *complete* unit-event descriptor-field
// observer set for `guid`, dispatching to `cb` on each watched field change.
// Mirrors the engine's own `FUN_0051bbb0` / `FUN_0051bdb0` (identical field
// set + per-field watched sizes), so `cb` fires for exactly the fields the
// engine treats as token-scoped unit events (`UNIT_HEALTH`, `UNIT_MANA`,
// `UNIT_AURA`, `UNIT_LEVEL`, …).
//
// This makes a synthetic token (focus, nameplateN) a first-class unit-event
// source: the engine only registers these observers for its own tokens
// (target/party/raid/pet/mouseover via the `unit+0xc90` watch mask), so a
// focused mob that isn't otherwise one of them fires no unit events until we
// watch it ourselves. The registrar appends a fresh node per (guid, field)
// with no dedup, so our observer coexists with the engine's — a unit that is
// simultaneously the target keeps firing `"target"` while ours fires its own
// token. Unregister is a safe no-op once the object is gone (the engine frees
// observer nodes with the object).
//
// `cb` runs inside SMSG_UPDATE_OBJECT dispatch; firing engine events from it
// is sanctioned (the engine's own callback `FUN_0051bd50` does exactly that).
void Register(uint64_t guid, FieldCallback cb);
void Unregister(uint64_t guid, FieldCallback cb);
} // namespace Unit::TokenObserver