Compare commits

...
34 Commits
Author SHA1 Message Date
Brues 46597f6587 unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.

Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.

Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
2026-07-30 00:45:00 -05:00
Brues 4e1c1863e9 debugtools: add Refresh button to TableInspector
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
2026-07-27 20:13:49 -05:00
Brues 1ae102b554 add string.split to workaround certain addons replacing strsplit 2026-07-27 15:41:23 -05:00
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
49 changed files with 3882 additions and 467 deletions
+6
View File
@@ -38,3 +38,9 @@ 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
Util\AuraDurationModifiers.lua
@@ -0,0 +1,70 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
-- off that trigger.
--
-- A trigger is matched by exact spellID (stable across ranks -- these are the
-- IDs the server binds its spell script to); the affected aura by
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
-- trigger is a whole class of spells rather than a named ability,
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
-- index instead (see Shadow Weaving below).
--
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
-- server-side probability roll on Ferocious Bite. The client can't know the
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
-- register it yourself, e.g. (DRUID = 7):
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
-- removal path, so the reduce rule below is harmless there and correct on
-- Turtle (where Immolate keeps ticking with 3s shaved off).
if TURTLE_WOW_VERSION then
local WARLOCK, SHAMAN = 5, 11
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
for i = 1, table.getn(CONFLAGRATE) do
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
end
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
for i = 1, table.getn(MOLTEN_BLAST) do
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
end
end
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
end
end)
+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 MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local mouseOver = DebugTools_IsMouseOver(frame);
if (mouseOver and (showHidden or (visOK and visible))) then
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
local nameOK, name = pcall(frame.GetName, frame);
if (not nameOK or not name) then name = "<anonymous>"; end
table.insert(matches, { name = name, frame = frame });
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+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
+11 -8
View File
@@ -23,6 +23,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
@@ -34,11 +35,12 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
@@ -46,7 +48,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `math.huge`, `math.modf`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.wipe` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -57,10 +59,10 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
@@ -68,8 +70,8 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -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` |
+639 -82
View File
@@ -34,6 +34,16 @@ build instructions.
- [Class](#class)
- [`FillLocalizedClassList(table [, isFemale])`](#filllocalizedclasslisttable-isfemale)
- [ColorUtil](#colorutil)
- [`C_ColorUtil.ConvertRGBToHSV(r, g, b)`](#c_colorutilconvertrgbtohsvr-g-b)
- [`C_ColorUtil.ConvertHSVToRGB(h, s, v)`](#c_colorutilconverthsvtorgbh-s-v)
- [`C_ColorUtil.ConvertHSVToHSL(h, s, v)`](#c_colorutilconverthsvtohslh-s-v)
- [`C_ColorUtil.ConvertHSLToHSV(h, s, l)`](#c_colorutilconverthsltohsvh-s-l)
- [`C_ColorUtil.ConvertHSLToRGB(h, s, l)`](#c_colorutilconverthsltorgbh-s-l)
- [`C_ColorUtil.GenerateTextColorCode(color)`](#c_colorutilgeneratetextcolorcodecolor)
- [`C_ColorUtil.WrapTextInColor(text, color)`](#c_colorutilwraptextincolortext-color)
- [`C_ColorUtil.WrapTextInColorCode(text, colorCode)`](#c_colorutilwraptextincolorcodetext-colorcode)
- [Combat](#combat)
- [`InCombatLockdown()`](#incombatlockdown)
@@ -119,6 +129,7 @@ build instructions.
- [`QUEST_REMOVED` event](#quest_removed-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)](#update_mouseover_unit-event-loss-fire-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [Expansion](#expansion)
@@ -145,10 +156,14 @@ build instructions.
- [Frame](#frame)
- [`region:SetPoint("point")` (one-argument form)](#regionsetpointpoint-one-argument-form)
- [`region:SetSize(width, height)` / `region:GetSize()`](#regionsetsizewidth-height--regiongetsize)
- [`region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])`](#regionismouseovertopoffset-bottomoffset-leftoffset-rightoffset)
- [`region:GetRect()`](#regiongetrect)
- [`region:IsDragging()`](#regionisdragging)
- [`frame:SetShown(shown)`](#framesetshownshown)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
- [`frame:IsEventRegistered(event)`](#frameiseventregisteredevent)
- [`frame:GetEffectiveAlpha()`](#framegeteffectivealpha)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
@@ -166,7 +181,7 @@ build instructions.
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`](#gametooltipsethyperlinkcompareitemitemlink--offset-shiftbutton-comparisontooltip)
- [`GameTooltip:IsEquippedItem()`](#gametooltipisequippeditem)
- [`OnTooltipSetItem` script](#ontooltipsetitem-script)
- [`OnTooltipSet*` scripts (Item / Spell / Unit / GameObject)](#ontooltipset-scripts)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:SetTotem(slot)`](#gametooltipsettotemslot)
@@ -188,6 +203,10 @@ build instructions.
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [Glue](#glue)
- [`C_Glue.IsFirstLoadThisSession()`](#c_glueisfirstloadthissession)
- [`C_Glue.IsOnGlueScreen()`](#c_glueisongluescreen)
- [Gossip](#gossip)
- [`C_GossipInfo.GetText()`](#c_gossipinfogettext)
- [`C_GossipInfo.GetOptions()`](#c_gossipinfogetoptions)
@@ -290,6 +309,8 @@ build instructions.
- [Lua](#lua)
- [`select(index, ...)`](#selectindex-)
- [`table.wipe(t)`](#tablewipet)
- [`table.count(tbl)`](#tablecounttbl)
- [`Mixin(object, ...)` / `CreateFromMixins(...)`](#mixinobject--createfrommixins)
- [`string.match` / `string.gmatch`](#stringmatch--stringgmatch)
- [`strsplit(sep, str [, pieces])`](#strsplitsep-str--pieces)
- [`strjoin(delimiter, ...)`](#strjoindelimiter-)
@@ -379,9 +400,6 @@ build instructions.
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
- [Screen](#screen)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [Spell](#spell)
- [`C_Spell.DoesSpellExist(spellID)`](#c_spelldoesspellexistspellid)
- [`C_Spell.GetSchoolString(schoolMask)`](#c_spellgetschoolstringschoolmask)
@@ -393,6 +411,7 @@ build instructions.
- [`C_Spell.GetSpellLink(spellID)`](#c_spellgetspelllinkspellid)
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
- [`C_Spell.GetSpellEffectMechanics(spellID)`](#c_spellgetspelleffectmechanicsspellid)
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
@@ -400,6 +419,7 @@ build instructions.
- [`IsPassiveSpell(spellID)` / `IsPassiveSpell(slot, bookType)`](#ispassivespellspellid--ispassivespellslot-booktype)
- [`C_Spell.IsSpellPassive(spellID)`](#c_spellisspellpassivespellid)
- [`IsPlayerSpell(spellID)`](#isplayerspellspellid)
- [`CanDualWield()`](#candualwield)
- [`IsSpellKnown(spellID, [isPet])`](#isspellknownspellid-ispet)
- [`GetSpellBonusDamage(school)`](#getspellbonusdamageschool)
- [`GetSpellBonusHealing()`](#getspellbonushealing)
@@ -440,6 +460,8 @@ build instructions.
- [`IsStealthed()`](#isstealthed)
- [`IsFalling()`](#isfalling)
- [`IsSwimming()`](#isswimming)
- [`IsIndoors()`](#isindoors)
- [`IsOutdoors()`](#isoutdoors)
- [`IsAssistingRitual()`](#isassistingritual)
- [`IsInGroup()`](#isingroup)
- [`IsInRaid()`](#isinraid)
@@ -447,6 +469,10 @@ build instructions.
- [`GetShapeshiftFormID()`](#getshapeshiftformid)
- [`CancelShapeshiftForm()`](#cancelshapeshiftform)
- [System](#system)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [`CopyToClipboard(text [, removeMarkup])`](#copytoclipboardtext--removemarkup)
- [Talent](#talent)
- [`GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`](#gettalentspellidtabindex-talentindex-rank-classid)
- [`GetTalentIDByIndex(tabIndex, talentIndex[, classID])`](#gettalentidbyindextabindex-talentindex-classid)
@@ -514,6 +540,7 @@ build instructions.
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitPosition(unit)`](#unitpositionunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`ClosestUnitPosition(creatureID)`](#closestunitpositioncreatureid)
- [`UnitHealthMissing(unit)`](#unithealthmissingunit)
@@ -529,6 +556,7 @@ build instructions.
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
- [`C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`](#c_unitaurasregistercombodurationspellid-baseseconds-maxseconds)
- [`C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`](#c_unitaurasregisterauradurationmodifiertriggerspellid-affectedfamily-affectedfamilyflags-affectedicon-op--valueseconds)
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
@@ -1058,6 +1086,80 @@ Sparse class IDs (vanilla skips classID 6 — Death Knight didn't
exist yet — and a few others) have NULL records and are silently
skipped.
## ColorUtil
The modern `C_ColorUtil` color-space and text-color-code helpers. All are
pure functions (no engine state); conventions match a retail-family client
that ships them natively, verified in-game.
**Ranges:** hue is in **degrees**, `[0, 360)`; saturation / value / lightness
are `[0, 1]`. RGB channels are `[0, 1]` (not 0–255). Achromatic (gray) inputs
return a hue of **`-1`** — a sentinel, not `0` — which the reverse conversions
accept as "no hue" (equivalent to `saturation == 0`).
The `color` argument is a table with `r`, `g`, `b` fields in `[0, 1]`
(`ColorMixin`-shaped). An optional `a` is honored (default `1`), though the
color code always encodes 8 hex digits with alpha.
### `C_ColorUtil.ConvertRGBToHSV(r, g, b)`
Returns `(h, s, v)`. Achromatic input → `h == -1`.
```lua
C_ColorUtil.ConvertRGBToHSV(0, 1, 0) -- 120, 1, 1 (pure green)
C_ColorUtil.ConvertRGBToHSV(0.5, 0.5, 0.5) -- -1, 0, 0.5 (gray)
```
### `C_ColorUtil.ConvertHSVToRGB(h, s, v)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is treated as achromatic
and yields `r == g == b == v`.
```lua
C_ColorUtil.ConvertHSVToRGB(120, 1, 1) -- 0, 1, 0
```
### `C_ColorUtil.ConvertHSVToHSL(h, s, v)`
Returns `(h, s, l)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToHSV(h, s, l)`
Returns `(h, s, v)`. Hue passes through unchanged.
### `C_ColorUtil.ConvertHSLToRGB(h, s, l)`
Returns `(r, g, b)`. A negative hue (or `s == 0`) is achromatic → `r == g ==
b == l`.
### `C_ColorUtil.GenerateTextColorCode(color)`
Returns the bare 8-hex color string `"AARRGGBB"` (alpha first), **not**
including the `|c` escape.
```lua
C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0}) -- "ffff0000"
```
### `C_ColorUtil.WrapTextInColor(text, color)`
Wraps `text` in the color's escape sequence: `"|c" .. AARRGGBB .. text ..
"|r"`.
```lua
C_ColorUtil.WrapTextInColor("Hi", {r = 1, g = 0, b = 0}) -- "|cffff0000Hi|r"
```
### `C_ColorUtil.WrapTextInColorCode(text, colorCode)`
Wraps `text` in the given bare color code: `"|c" .. colorCode .. text ..
"|r"`. Pair with `GenerateTextColorCode` (which produces `colorCode`).
```lua
local code = C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0})
C_ColorUtil.WrapTextInColorCode("Hi", code) -- "|cffff0000Hi|r"
```
## Combat
### `InCombatLockdown()`
@@ -3022,6 +3124,60 @@ byte reads (cheap) and naturally handles the case where one packet
carries multiple rep updates whose threshold crossings ripple AT_WAR
state across unrelated slots.
### `UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)
`UPDATE_MOUSEOVER_UNIT` exists in the vanilla event table and fires when a
mouseover unit is **gained**, but the 1.12 engine **never fires it on
loss** — moving the cursor off a unit clears the mouseover and signals
nothing. Modern WoW fires it for both gain and loss, so addons observing
`UnitExists("mouseover")` can react when it becomes false. We restore the
loss fire.
Payload is unchanged (none in vanilla — same as retail): the event
carries no args; handlers read `UnitExists("mouseover")`, which is
`nil`/false at fire time on the loss path.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
f:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" then
if UnitExists("mouseover") then
-- gained (or changed to) a mouseover unit
else
-- mouseover unit lost (the vanilla gap this fills)
end
end
end)
```
**Transitions and who fires them:**
| From | To | Fires? | Source |
|------|----|:------:|--------|
| unit | nothing | yes | ClassicAPI (silent in stock 1.12) |
| unit | gameobject | yes | ClassicAPI (mouseover unit genuinely lost) |
| gameobject | nothing | no | — (never fired a gain; not a unit) |
| anything | unit | yes | engine's own inline fire (gain / unit→unit) |
**Implementation notes**
The mouseover GUID lives in two globals written **only** by the engine's
mouseover set/clear chokepoint (`0x00492890`), so every gain / loss /
change flows through it — on a unit gain it fires `UPDATE_MOUSEOVER_UNIT`
itself, on loss it clears the GUID silently. We co-hook that function:
resolve whether the mouseover is a *unit* before and after the original,
and fire on the unit→non-unit transition. Gating on unit *type* (not just
"GUID present → absent") is load-bearing — the same GUID slot also holds
gameobjects and items, which never fire a gain, so the type check is what
keeps moving off a herb node or chest from spuriously firing. The
`!hasUnit` half prevents a double-fire on any →unit transition the engine
already covered.
The target is change-gated by its callers (if it ran per-frame the gain
event would spam while hovering), so it's a cool hook — no per-frame
overhead.
### `UPDATE_SHAPESHIFT_FORM` event
Fires whenever the local player's shapeshift form changes — entering
@@ -3504,6 +3660,52 @@ The modern combined setter/getter for `SetWidth`+`SetHeight` /
textures (including engine-created ones like
`button:GetNormalTexture()`), fontstrings.
### `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])`
`true` when the mouse cursor is within the region's rectangle, `false`
otherwise. The four optional offsets shift the corresponding edge before
the test (added to that edge, matching modern semantics: positive
`topOffset` / `rightOffset` and negative `bottomOffset` / `leftOffset`
enlarge the hit area). Registered on the Region base, so it works on any
region type — frames, buttons, textures, fontstrings.
Uses the engine's own rect getters and cursor position, dividing the
cursor by the region's effective scale — the exact computation vanilla
addons have long open-coded as `MouseIsOver()`. Returns `false` for a
region with no resolved rect (unpositioned / zero-size).
```lua
ItemRefTooltip:IsMouseOver() -- true while hovering the tooltip
Minimap:IsMouseOver(20, -20, -20, 20) -- true within a 20px halo around it
```
### `region:GetRect()`
Returns the region's `left, bottom, width, height` in one call (the
modern combined form of `GetLeft`+`GetBottom`+`GetWidth`+`GetHeight`).
Composed from the engine's own getters, so all UI-scale conversion is its
code. On the Region base — works on any region type. Returns nothing for
a region with no resolved rect (unpositioned), matching retail.
```lua
UIParent:GetRect() -- 0, 0, <screenWidth>, <screenHeight>
Minimap:GetRect() -- <left>, <bottom>, 140, 140
```
### `region:IsDragging()`
`true` while the region is the frame currently being moved or resized by
the mouse — i.e. after `StartMoving`/`StartSizing` and before
`StopMovingOrSizing` — `false` otherwise. Reads the engine's single global
drag target (the same slot `StopMovingOrSizing` checks to decide whether
it owns the active drag). On the Region base; non-frame regions (textures,
fontstrings) always report `false`, since only frames can be dragged.
```lua
-- in a frame's OnUpdate while the user drags it:
if self:IsDragging() then ... end
```
### `frame:SetShown(shown)`
`Show()` if `shown` is truthy, `Hide()` otherwise. Registered for
@@ -3552,6 +3754,22 @@ f:IsEventRegistered("PLAYER_LOGIN") -- true
f:IsEventRegistered("PLAYER_LOGOUT") -- false
```
### `frame:GetEffectiveAlpha()`
Returns the frame's **effective** alpha — its own `GetAlpha()` multiplied
by every ancestor's, i.e. the opacity WoW actually composites at render
time (fading `UIParent` fades all of its children). Vanilla exposes only
`GetAlpha` (a frame's own alpha); this walks `self → parent → …` via the
engine's own `GetAlpha`/`GetParent`, so it sees exactly what the engine
does — including the 8-bit quantization of alpha (`SetAlpha(0.5)` stores
`127/255 ≈ 0.498`, and effective alpha is the product of those).
```lua
UIParent:GetEffectiveAlpha() -- 1
-- with UIParent at 0.5 and Minimap's own alpha 1:
Minimap:GetEffectiveAlpha() -- ~0.498 (0.5 truncates to 127/255)
```
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -4158,18 +4376,27 @@ GameTooltip:SetBagItem(0, 1)
if GameTooltip:IsEquippedItem() then ... end -- false unless that item is also worn
```
### `OnTooltipSetItem` script
### `OnTooltipSet*` scripts
A real frame script — settable with the standard `SetScript` / `GetScript` /
`HookScript` — that fires whenever a tooltip's **item** is set. Backports the
modern tooltip script so addons annotate item tooltips by hooking one script
instead of wrapping every `Set*` method.
Fires for every item-setting path (`SetBagItem`, `SetInventoryItem`,
`SetHyperlink` for an `item:` link, `SetMerchantItem`, `SetAuctionItem`,
`SetItemByID`, …). Available on all GameTooltip-type frames (`GameTooltip`,
`OnTooltipSetItem` / `OnTooltipSetSpell` / `OnTooltipSetUnit` /
`OnTooltipSetGameObject` — real frame scripts, settable with the standard
`SetScript` / `GetScript` /
`HookScript` — that fire whenever a tooltip's **item**, **spell**, **unit**, or
**gameobject** is set. Backport the modern tooltip scripts so addons annotate
tooltips by hooking one script per object type instead of wrapping every
`Set*` method. Available on all GameTooltip-type frames (`GameTooltip`,
`ItemRefTooltip`, `ShoppingTooltip1/2`, `AtlasLootTooltip`, …).
| Script | Fires after any of |
|---|---|
| `OnTooltipSetItem` | `SetBagItem`, `SetInventoryItem`, `SetHyperlink` (`item:`), `SetMerchantItem`, `SetAuctionItem`, `SetItemByID`, … |
| `OnTooltipSetSpell` | `SetSpell`, `SetSpellByID`, `SetTalent`, `SetShapeshift` |
| `OnTooltipSetUnit` | `SetUnit` and unit mouseover |
| `OnTooltipSetGameObject` | gameobject mouseover (herb/ore nodes, chests, mailboxes, signs) |
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; these four are the backported set.
The handler receives the tooltip as the **global `this`**, the 1.12
frame-script convention — *not* a `self` argument (like every built-in vanilla
script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
@@ -4178,21 +4405,25 @@ script: `OnShow`, `OnEvent`, `OnTooltipCleared`, …). Modern-style
```lua
GameTooltip:HookScript("OnTooltipSetItem", function()
local name, link, id = this:GetItem() -- the tooltip is the global `this`
if id then
this:AddLine("ID: " .. id, 0.6, 0.6, 0.6)
end
if id then this:AddLine("ID: " .. id, 0.6, 0.6, 0.6) end
end)
GameTooltip:HookScript("OnTooltipSetUnit", function()
local name, unit = this:GetUnit()
if unit then this:AddLine(UnitClassBase(unit), 0.6, 0.6, 0.6) end
end)
```
Vanilla only shipped `OnTooltipAddMoney` / `OnTooltipCleared` /
`OnTooltipSetDefaultAnchor`; `OnTooltipSetSpell` / `OnTooltipSetUnit` are not
yet provided.
**Auras don't fire `OnTooltipSetSpell`** — `SetUnitBuff` / `SetUnitDebuff` /
`SetPlayerBuff` / `SetUnitAura` use a separate aura-tooltip builder, so hovering
a buff/debuff does not trigger the spell script. This matches retail, where unit
auras are a distinct tooltip data type rather than a spell.
**Caveat — the event fires from inside the item-tooltip build.** Lightweight
handler work is fine: reading `this:GetItem()`, `this:AddLine(...)`, printing,
etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because
the handler runs mid-build, re-entering the tooltip / FrameScript machinery can
**Caveat — the event fires from inside the tooltip build.** Lightweight handler
work is fine: reading `this:GetItem()` / `this:GetUnit()`, `this:AddLine(...)`,
printing, etc. But avoid *re-entrant* tooltip rebuilds from the handler — e.g.
`GameTooltip_ShowCompareItem()`, or a `Set*` call on another tooltip. Because the
handler runs mid-build, re-entering the tooltip / FrameScript machinery can
collide with other DLLs that hook the same Lua paths (nampower, SuperWoW,
weirdutils, …) and crash the client. If you need heavy work like that, defer it
to the next frame:
@@ -4539,6 +4770,47 @@ not modern's `ComboPoints` reuse of the same number.
local rage = UnitPower("player", Enum.PowerType.Rage)
```
## Glue
Backports of two `C_Glue` session helpers. `C_Glue` is registered on the glue
(login / character-select) Lua state; `IsOnGlueScreen` is *also* registered
in-game, so code that runs in either environment can branch on it. Neither
needs an engine flag — both answers fall out of which glue boot / Lua state
is asking.
### `C_Glue.IsFirstLoadThisSession()`
Returns `true` only while the **first** glue screen since the process
launched is showing, `false` on every later return to the glue screen. Login
↔ character-select transitions stay within the first glue session (they never
enter the world), so it stays `true` across them — it flips `false` only once
you've entered the world and logged back out. Glue state only.
```lua
if C_Glue.IsFirstLoadThisSession() then
-- one-time-per-launch startup (intro cinematic, news, …)
end
```
Backed by a process-static count of glue boots (the glue script-registration
pass fires once per boot: initial launch + each world→glue logout); boot 1 is
the first load.
### `C_Glue.IsOnGlueScreen()`
Returns `true` when a GlueXML screen is showing (login or character select —
no character in the world), `false` in the world. Registered on **both** Lua
states, so it's callable from glue and in-game code alike. The two states are
mutually exclusive (the glue state exists only when no character is in the
world), so the answer is fixed per state — `true` from the glue registration,
`false` from the in-game one.
```lua
if C_Glue.IsOnGlueScreen() then
-- at login / character select
end
```
## Gossip
Retail-shaped wrappers around vanilla's flat `GetGossipText` /
@@ -7115,6 +7387,55 @@ undefined per the Lua reference manual.
Errors on non-table input.
### `table.count(tbl)`
Returns `(numTableNodes, numArrayNodes, maxArrayIndex)` describing how a table
is populated (a modern WoW diagnostic, added retail 11.2.5). These are counts
of live entries, **not** the table's allocated capacity:
- **`numTableNodes`** — total number of key/value pairs.
- **`numArrayNodes`** — how many of those have an integer key in the range
`[1..numTableNodes]` (the "contiguous array part" heuristic).
- **`maxArrayIndex`** — the largest positive integral key (`>= 1`), or `0` if
there is none. Negative/zero integral keys don't count.
```lua
table.count({ 10, 20, 30 }) -- 3, 3, 3
table.count({ a = 1, b = 2 }) -- 2, 0, 0
table.count({ [1] = "x", foo = "y" }) -- 2, 1, 1
table.count({ [100] = "x" }) -- 1, 0, 100
table.count({ [-3] = "x" }) -- 1, 0, 0
```
Pure iteration over the table (`lua_next`), so no dependency on Lua's internal
storage layout. Errors on non-table input.
### `Mixin(object, ...)` / `CreateFromMixins(...)`
The FrameXML table-mixin primitives, provided as engine C functions.
- **`Mixin(object, ...)`** — shallow-copies every key/value from each table
argument onto `object` (later arguments win on key collisions) and returns
`object`. Non-table arguments are skipped.
- **`CreateFromMixins(...)`** — `Mixin({}, ...)`: returns a new table seeded
from the given mixins.
```lua
local Greeter = {}
function Greeter:Hello() return "hi, "..self.name end
local obj = CreateFromMixins(Greeter)
obj.name = "Bob"
obj:Hello() -- "hi, Bob"
```
Modern WoW provides these as C functions in `TableUtil`; ClassicAPI does the
same (rather than leaving them in the `!!!ClassicAPI` addon) so they exist
whenever the DLL is injected — even if the addon is disabled. The composite
`CreateAndInitFromMixin(mixin, ...)` (which calls a `:Init` method) remains a
Lua helper in the addon, mirroring retail's `Mixin.lua`. The taint-based
`SecureMixin` family is intentionally omitted — vanilla 1.12 has no execution
taint system, so there is nothing for it to guard.
### `string.match` / `string.gmatch`
The Lua 5.1 pattern helpers, backported to 1.12's Lua 5.0 (which ships only
@@ -9181,40 +9502,6 @@ A separate function keeps the existing call wire-compatible.
> first, skip zero slots, then the item array. 1-based `objectiveIndex`
> counts only non-empty slots.
## Screen
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
## Spell
> **All `C_Spell.*` functions in this section accept any spell
@@ -9523,6 +9810,41 @@ column is exactly what this function returns):
> `14` (incapacitated) in 1.12 — the `30` ("sapped") value used by some
> addon tables is a later-expansion addition and has no row here.
### `C_Spell.GetSpellEffectMechanics(spellID)`
Returns the spell's three per-effect `SpellMechanic` ids (`Spell.dbc`
`EffectMechanic[3]`) as a 1-based array table, or `nil` for an invalid /
out-of-range spell ID. Each entry uses the same numbering as
[`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid) (`0` = that
effect carries no mechanic).
```lua
C_Spell.GetSpellEffectMechanics(1822) -- Rake → { 0, 15, 0 }
C_Spell.GetSpellEffectMechanics(703) -- Garrote → { 0, 0, 0 }
C_Spell.GetSpellEffectMechanics(133) -- Fireball → { 0, 0, 0 }
```
Complements [`GetSpellMechanicByID`](#c_spellgetspellmechanicbyidspellid),
which reads only the **spell-level** `Mechanic` field (`+0x14`). Vanilla
frequently stores a spell's mechanic on an **effect** instead — periodic
damage such as bleeds is the common case. Garrote/Rupture/Rend/Rip tag
`bleeding` (15) at the spell level, but **Rake** has spell-level `0` and
`EffectMechanic[2] = 15`, so effect-mechanic-aware callers (e.g. bleed
classification for immunity tracking) need this array:
```lua
local function IsBleed(spellID)
if C_Spell.GetSpellMechanicByID(spellID) == 15 then return true end
local em = C_Spell.GetSpellEffectMechanics(spellID)
if em then for i = 1, 3 do if em[i] == 15 then return true end end end
return false
end
```
Reads `Spell.dbc` directly (`EffectMechanic[3]` at `+0x13C`), so it covers
every spell the client knows — not just the player's spellbook — with no
caching or network round-trip.
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
@@ -9703,6 +10025,23 @@ Reads a single bit from the engine's spell-knowledge bitmap at
The same lookup the engine itself does internally. No spellbook walk,
no talent walk, no profession-window dependency.
### `CanDualWield()`
Returns `true` if the player can equip a weapon in the off hand.
```lua
if CanDualWield() then ... end -- true for a warrior/rogue past level 10, etc.
```
Server-side this is a plain flag (`Player::m_canDualWield`) that starts
`false` and is turned on **only** by the `SPELL_EFFECT_DUAL_WIELD` spell
effect — no class has it innately. Exactly one spell in the 1.12 client's
`Spell.dbc` carries that effect: `674` ("Dual Wield"), the trained
warrior / rogue / hunter passive, which lands in the known-spell bitmap
like any learned spell. So `CanDualWield()` is precisely
`IsPlayerSpell(674)` — there is no separate client-visible capability
field to read.
### `IsSpellKnown(spellID, [isPet])`
Returns `true` if the given spellID is currently in the player's (or
@@ -10305,34 +10644,47 @@ the item-use path for grenades / on-use ground-target items.
### `C_Spell.CastAtUnit(spellIDOrName, unit)`
Casts a ground-target spell at `unit`'s feet, bypassing the AoE
reticle click. ClassicAPI's analog of modern's
`/cast [@unit] Flamestrike` — `CastAtUnit(spell, "player")` drops it
at the player's position, `"target"` at the target's, and so on for
any unit token (`"mouseover"`, `"party1"`, `"raid7"`, …). Returns
`true` when the placement landed at the unit; `false` for
non-ground-target spells (cast still fires normally), unknown
spells, or a unit with no resolvable position.
Casts a spell **at `unit`, whatever its target type** — ClassicAPI's
analog of modern's `/cast [@unit] Spell`:
- **Ground-target spells** (Flamestrike, Blizzard, Rain of Fire, …) are
placed at the unit's feet, bypassing the AoE reticle click.
- **Unit-target / normal spells** (Frostbolt, Renew, a buff, …) fire
directly on the unit — regardless of your current selection, so you can
buff `"party1"` or heal `"player"` while an enemy stays targeted.
Works with any unit token (`"target"`, `"player"`, `"mouseover"`,
`"party1"`, `"raid7"`, …). Returns `true` when the spell was cast at the
unit (fired or placed); `false` for a spell not in your spellbook, a unit
with no resolvable position, or a unit the engine won't accept as a target
for that spell (wrong faction, out of range).
The first argument takes a spellID or a spell name, with the same
exact-rank / `(Rank N)` semantics as `CastAtCursor`.
```lua
-- ground-target: dropped at the unit's feet
C_Spell.CastAtUnit(2120, "target") -- Flamestrike at the target's feet
C_Spell.CastAtUnit(10, "player") -- Blizzard Rank 1 on yourself
C_Spell.CastAtUnit("Blizzard(Rank 6)", "target")
-- unit-target / normal: cast straight on the unit, ignoring current target
C_Spell.CastAtUnit("Frostbolt", "target")
C_Spell.CastAtUnit("Renew", "player") -- heal yourself mid-fight
C_Spell.CastAtUnit("Power Word: Fortitude", "party1")
```
Same two-stage shape as
[`C_Spell.CastAtCursor`](#c_spellcastatcursorspellidorname), but instead of
the cursor raycast, step 2 reads the unit's world position from its
`GetPosition` virtual (the same one `CheckInteractDistance` /
`UnitInRange` use) and commits placement there via
`FUN_COMMIT_PLACEMENT_COORDS`. The unit is resolved *before* the cast
is dispatched, so an absent/invalid unit fails cleanly without
leaving the engine armed in placement mode. A genuinely unrecognized
unit-token string raises the engine's standard "Unknown unit" error,
matching `UnitHealth("garbage")`.
The unit is resolved once to its object; the cast is dispatched with the
unit's **GUID** as the implicit target (the engine substitutes it exactly
where it would the current selection), so a unit-target spell fires
immediately at that unit. A ground spell ignores the GUID and enters
placement mode instead, which is then committed at the unit's world
position (read from its `GetPosition` virtual — the same one
`CheckInteractDistance` / `UnitInRange` use) via
`FUN_COMMIT_PLACEMENT_COORDS`. The engine can't be left armed in placement
mode on failure: if no placement is active after dispatch the cast already
went out, and a non-ground placement (an unaccepted unit target) is
cancelled. A genuinely unrecognized unit-token string raises the engine's
standard "Unknown unit" error, matching `UnitHealth("garbage")`.
The companion item version is
[`C_Item.UseAtUnit`](#c_itemuseatunititeminfo-unit).
@@ -10805,6 +11157,37 @@ if IsSwimming() then
end
```
### `IsIndoors()`
Returns `1` if the local player is under a WMO roof — inside a building,
cave, or instance interior — and `nil` otherwise. `nil` before the player
object exists (pre-world).
Recomputed live from the engine's WMO geometry query, so it flips as you
cross an interior threshold rather than tracking the current zone/area —
you can be indoors and outdoors within the same subzone. Returns `1`/`nil`
(not `true`/`false`) to match the historical retail contract and
SuperWoW 2.2, so both `if IsIndoors()` and `IsIndoors() == 1` work.
```lua
if IsIndoors() then
-- suppress a mount cast that would just fail
end
```
### `IsOutdoors()`
Returns `1` if the local player is outdoors (open sky, or simply not
inside a WMO interior) and `nil` otherwise. Exact complement of
[`IsIndoors()`](#isindoors) for a resolvable player; both return `nil`
pre-world.
```lua
if IsOutdoors() then
CastSpellByName("Mount Up")
end
```
### `IsAssistingRitual()`
Returns `true` if the local player is currently clicking a warlock
@@ -11019,6 +11402,78 @@ Mirrors 3.3.5a's `Script_CancelShapeshiftForm` inner (`FUN_00726CE0`),
which does the same effect-array scan + form-id match before issuing
its cancel packet. Vanilla just lacks the public Lua surface.
## System
Host/OS-level helpers that aren't tied to a game domain.
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
### `CopyToClipboard(text [, removeMarkup])`
Copies `text` to the Windows clipboard and returns the number of bytes
copied. Vanilla has no clipboard access at all, so this is a ClassicAPI
addition backed by the Win32 clipboard API. Available on both the in-game
and login/character-select screens.
- `text` — the string to copy (numbers are coerced, like Lua does).
- `removeMarkup` — optional; when truthy, strip WoW UI escape sequences
before copying so the clipboard gets plain text:
- `|cAARRGGBB … |r` color codes → removed
- `|H…|h text |h` hyperlinks → keeps only the visible text
- `|T…|t` textures → removed
- `|n` → newline, `||` → literal `|`
- unknown `|x` sequences are left verbatim, so real text that merely
contains a pipe survives
- returns the byte length of the copied (possibly stripped) string —
consistent with Lua's `#s`. `0` for an empty string or if the clipboard
couldn't be opened.
```lua
CopyToClipboard("hello world") -- 11
local link = "\124cffff0000Red\124r" -- real single-pipe markup
CopyToClipboard(link, true) -- 3 (clipboard gets "Red")
CopyToClipboard(link) -- 15 (raw escapes copied)
```
> The string is treated as UTF-8 (matching the credential and TTS modules)
> and written as `CF_UNICODETEXT`; Windows synthesizes `CF_TEXT` for legacy
> consumers.
>
> Note when testing from the chat box: the edit box escapes every typed `|`
> into `||`, so a literal `|cff…` you type is no longer a color code by the
> time it reaches Lua. Build real markup with `\124` (as above) to exercise
> `removeMarkup`.
## Talent
### `GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])`
@@ -12243,17 +12698,58 @@ human.
Reads world positions via the `CGObject::GetPosition` vtable virtual
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
which is left untouched).
`CheckInteractDistance` and our own [`UnitPosition`](#unitpositionunit)
use. **Center-to-center**, not reach-aware (that edge-to-edge nicety is
UnitXP_SP3's niche); the value equals what you'd compute from the raw
`UnitPosition` coordinates, and it's self-contained (no sibling-DLL
dependency).
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitPosition(unit)`
Returns `posY, posX, posZ, instanceID` — the unit's world position, in
the modern (WoD+) `UnitPosition` shape. `nil` when the unit has no known
position.
```lua
local posY, posX, posZ, instanceID = UnitPosition("player")
```
| Return | Meaning |
|--------|---------|
| `posY` | World Y coordinate (the west axis). |
| `posX` | World X coordinate (the north axis). |
| `posZ` | World Z coordinate (height). |
| `instanceID` | Currently-loaded map id (`Map.dbc` row — e.g. `0` Eastern Kingdoms, `1` Kalimdor). Every visible unit shares the player's instance. |
**Order matches retail** — `posY` (west) first, then `posX` (north), then
`posZ`, mirroring Blizzard's own `UnitPosition`. So an addon written for
retail that does `local py, px = UnitPosition(u)` gets its coordinates
labelled as expected. (World X is north/south, Y is west/east — WoW's
long-standing convention.)
**Not group-restricted.** Retail returns `nil` for units outside your
party/raid (a privacy guard that doesn't exist in 1.12). This backport
reads any *visible* unit — `"target"`, `"mouseover"`, nameplate tokens,
arbitrary party/raid members in sync range — so it's strictly more
permissive, closer to SuperWoW's unit-position access. Units the client
can't currently see (out of range, another zone) have no position → `nil`.
Reads through the same `CGObject::GetPosition` virtual as
[`UnitDistanceSquared`](#unitdistancesquaredunit); the two are consistent
(the squared delta of two `UnitPosition` reads equals
`UnitDistanceSquared`).
> **SuperWoW interaction.** SuperWoW also defines a global `UnitPosition`
> with a different return shape. Both register on the same Lua state, so
> if SuperWoW is loaded the last registrant wins — install order decides
> which shape is live. Without SuperWoW, this retail-shaped version is the
> one you get.
### `UnitInLineOfSight(unit)`
Returns `true` if the player has clear line of sight to `unit`, `false`
@@ -12767,6 +13263,67 @@ is the escape hatch for *other* custom servers with the same class of
gap — call it from your own config or addon; a registered override
beats both the DBC and the built-in values.
### `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`
ClassicAPI extension for **server-side DoT-duration changes on another
unit that the client is never told about**. Returns `true` on success.
`expirationTime` for a debuff on another unit is computed from the cast
packet (`SMSG_SPELL_GO`) at cast time. When the server later changes that
debuff's remaining duration — a refresh, or a partial consume — 1.12 sends
**no** packet an observing caster can see (verified in the server source:
the change runs through `SetAuraDuration`/`RefreshHolder`, and the only
duration packet is self-scoped to the aura-bearer; on a mob it isn't even
built). So the cached `expirationTime` would go stale.
The client *does* always see the **triggering** cast, though. Register a
rule and ClassicAPI mirrors the server's edit on the cached aura when that
trigger lands:
| Arg | Meaning |
|-----|---------|
| `triggerSpellID` | The spell whose cast triggers the change (match one rank per call — use the exact IDs the server binds). |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `7` druid). |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
| `valueSeconds` | Amount for `reduce`/`set`; ignored otherwise. |
The rule only fires for the aura **cast by the same unit** as the trigger
(these mechanics act on the caster's own DoT), and misses are excluded for
free (a missed trigger isn't in the packet's hit list).
**`C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`**
is the same, but matches the *trigger* by `SpellFamilyName` + school index
(`0` physical … `2` fire, `5` shadow, `6` arcane; `< 0` = any) instead of an
exact spellID — one rule covers a whole class of spells (every rank, plus
server-added ones). Use it when the trigger is a category rather than a named
ability.
`!!!ClassicAPI` ships a default Turtle ruleset
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua)):
Conflagrate shaves 3s off the caster's Immolate, Molten Blast refreshes the
caster's Flame Shock, and — for a 5/5 Shadow Weaving priest (talent `15334`,
where the proc is 100%) — any shadow-school priest cast refreshes the target's
Shadow Vulnerability (`15258`). Deliberately excluded: probabilistic effects
(Carnage's roll-gated Rip/Rake refresh, and Shadow Weaving below 5/5) — the
client can't see the server's roll, so inferring them would show wrong timers.
Two accepted best-effort caveats on the Shadow Weaving rule: DoT *ticks*
(SW:P / Devouring Plague) also refresh it server-side but emit no cast packet,
so a pure DoT-only phase under-counts slightly; and a full-consume Conflagrate
variant on other servers *removes* Immolate, which ClassicAPI already evicts
via the normal removal path (so the reduce rule is harmless there).
```lua
-- Flags are hex; Lua 5.0 has no 0x literals, so use tonumber(hex, 16).
-- Conflagrate (Rank 4) shaves 3s off the caster's Immolate (warlock, flag 0x4):
C_UnitAuras.RegisterAuraDurationModifier(18932, 5, tonumber("4", 16), 0, "reduce", 3)
-- Molten Blast refreshes the caster's Flame Shock (shaman, flag 0x10000000):
C_UnitAuras.RegisterAuraDurationModifier(36916, 11, tonumber("10000000", 16), 0, "refresh")
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability (15258):
C_UnitAuras.RegisterAuraDurationModifierByTrigger(6, 5, 6, tonumber("4000000", 16), 9, "refresh")
```
### `C_UnitAuras.GetUnitAuras(unit [, filter])`
Returns a numerically-indexed array of `AuraData` tables for every
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+106
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
@@ -299,6 +316,16 @@ enum Offsets {
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Inner name formatter behind FUN_ITEM_BUILD_INSTANCE_NAME, callable
// WITHOUT a CGItem: `__fastcall(char *out /*ecx*/, uint outSize /*edx*/,
// uint32 itemID, int suffixID)`. Fetches the ItemStats record for itemID
// and, when suffixID is a valid ItemRandomProperties row with a localized
// suffix name (record +0x1c + locale*4), formats base + suffix via
// ITEM_SUFFIX_TEMPLATE; otherwise the base name. Lets the by-STRING item
// paths (GetItemNameByID, C_Item.GetItemInfo) apply an item link's random
// suffix without a live item instance.
FUN_ITEM_BUILD_NAME_FROM_ID = 0x005D8B00,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -527,6 +554,31 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
// these (see the token-dispatch list above). Written ONLY by
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
// `Unit::Mouseover` to detect the loss transition.
VAR_MOUSEOVER_GUID_LO = 0x00B4E2C8,
VAR_MOUSEOVER_GUID_HI = 0x00B4E2CC,
// The engine's single mouseover set/clear chokepoint —
// `__stdcall(guidLo, guidHi, arg3, arg4)` (early returns are all
// `ret 0x10`, and the caller pushes 4 without cleaning). It is the
// ONLY writer of `VAR_MOUSEOVER_GUID_*`, so every mouseover
// gain/loss/change flows through it. On a unit gain it fires
// `UPDATE_MOUSEOVER_UNIT` itself (via `FUN_FIRE_EVENT_NO_ARGS(0x150)`
// in the friendly/hostile-unit tooltip case); on loss it clears the
// GUID but fires nothing — the gap `Unit::Mouseover` closes.
// Change-gated by its callers (if it ran per-frame the gain fire
// would spam every frame while hovering, which vanilla doesn't), so
// it's a cool hook target.
FUN_SET_MOUSEOVER_UNIT = 0x00492890,
// Event ID of `UPDATE_MOUSEOVER_UNIT` (name-table slot
// `0x00BE16D8` → `(0x16D8 - 0x1198) / 4`). Fired with no args via
// `FUN_FIRE_EVENT_NO_ARGS`; Lua handlers read `UnitExists("mouseover")`.
EVENT_UPDATE_MOUSEOVER_UNIT = 0x150,
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
// — the function ends with `ret 0xc`, so the callee pops the
@@ -1297,12 +1349,42 @@ enum Offsets {
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
// Region rect getters (return a number, or nil when the region has no
// resolved rect). All on the REGION base registry → callable on any
// region type. Used by Frame::Modern's IsMouseOver.
FUN_SCRIPT_REGION_GETLEFT = 0x007A1980,
FUN_SCRIPT_REGION_GETRIGHT = 0x007A1AA0,
FUN_SCRIPT_REGION_GETTOP = 0x007A1BC0,
FUN_SCRIPT_REGION_GETBOTTOM = 0x007A1CE0,
// In-game GetCursorPosition (0x0046DAD0 is the glue-state copy). Pushes
// (x, y) in the same k = FUN_0041ad70()*DAT_007ffd68 space the region
// rect getters use — so dividing by the region's effective scale puts
// the cursor in the region's logical coordinate space.
FUN_SCRIPT_GETCURSORPOSITION = 0x0048B820,
// Region effective-scale field: `piVar[0x1f]` (float) read by both
// GetEffectiveScale (0x00774FC0) and internally by GetLeft/Top/etc. —
// present on every region object, so it's the all-region way to get the
// effective scale without the Frame-only GetEffectiveScale method.
OFF_REGION_EFFECTIVE_SCALE = 0x7C,
// Global UI/screen context pointer (every frame's +0xA0 field mirrors
// it; GetCursorPosition reads the cursor from *ptr + 0x1118). Its
// +0xCFC slot holds the frame currently being moved/sized via
// StartMoving/StartSizing (0 when idle) — the drag target. Backs
// Frame::Modern's IsDragging: StopMovingOrSizing (0x00776990) gates its
// clear on `*(context+0xCFC) == self`, i.e. the IsDragging predicate.
VAR_UI_CONTEXT_PTR = 0x00CF0BD8,
OFF_UI_CONTEXT_DRAG_TARGET = 0xCFC,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
// Frame GetAlpha (own alpha, 0..1) + Region GetParent — walked by
// Frame::Modern's GetEffectiveAlpha up the parent chain.
FUN_SCRIPT_FRAME_GETALPHA = 0x00774DC0,
FUN_SCRIPT_REGION_GETPARENT = 0x007A1460,
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
@@ -4657,6 +4739,23 @@ enum Offsets {
// `castGameObject != 0`).
OFF_CGPLAYER_CAST_GAMEOBJECT_PTR = 0xB4,
// Indoor/outdoor query for a unit — backs `IsIndoors()` / `IsOutdoors()`
// (`unit/State.cpp`). `__fastcall(unit) -> uint` where **nonzero =
// outdoors**, 0 = indoors. A 2-instruction thunk: reads the unit's
// world/collision-context pointer at `unit + 0xE0` and tail-jumps to the
// WMO query `FUN_006706f0`. That query is fully null-safe — null context
// or a unit not inside a WMO group returns 1 (outdoors); otherwise it
// resolves the current WMO group via `FUN_0069d4f0` and returns bit 15
// (`0x8000`) of the group's runtime flags word (the OUTDOOR bit).
//
// Derived from SuperWoW 2.2's `IsIndoors` handler (which does exactly
// `unit = ObjMgr(0x10, PlayerGUID()); push (query(unit)==0)`), then
// confirmed against the vanilla binary. Vanilla ships no Lua binding for
// this; the 3.3.5 client's `IsIndoors` (`0x00612300`) uses the identical
// shape (`unit + 0xB8` → WMO group flag test), so the semantics are
// cross-checked across both clients.
FUN_UNIT_IS_OUTDOORS = 0x00612cc0,
// No `MOVEFLAG_FLYING` constant intentionally. Vanilla 1.12
// doesn't flip a flying bit during taxi flights (verified
// empirically: `IsFlying()` checking 0x01000000 stayed false
@@ -5321,6 +5420,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
@@ -5364,6 +5469,7 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ICON_ID = 0x1D4, // u32 SpellIconID (→ SpellIcon.dbc)
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
+145
View File
@@ -140,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;
}
@@ -677,6 +693,101 @@ 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,
@@ -686,6 +797,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
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;
@@ -699,6 +811,10 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
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;
}
@@ -709,6 +825,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
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;
@@ -724,6 +841,17 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
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;
}
@@ -734,6 +862,7 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
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;
@@ -753,6 +882,17 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
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;
}
@@ -763,6 +903,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
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);
@@ -774,6 +915,10 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
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
+420 -2
View File
@@ -27,6 +27,7 @@
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace Aura::Source {
@@ -70,6 +71,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 +179,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 —
@@ -152,10 +235,271 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
//
// Some server mechanics change a DoT's remaining time on another unit with
// NO client-visible signal — verified in tortoise-wow: the change goes
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules come from Lua
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
// lives in the addon, not the DLL. Turtle examples:
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched by exact spellID (from the server's script binding,
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
// deliberately NOT shipped as defaults — the client can't know the server's
// roll outcome, so inferring them would show wrong timers.
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
struct DurationMod {
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
uint32_t affectedFamily; // SpellFamilyName of the affected aura
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
int32_t op;
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
};
constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
return false;
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if ((flags & m.affectedMask) == 0)
return false;
if (m.affectedIcon != 0 &&
*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
return false;
return true;
}
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
// by the cast spell's SpellFamilyName + school index — one rule then covers
// every rank / server-added spell of a class's school (e.g. any priest
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
if (m.triggerSpellId != 0)
return m.triggerSpellId == triggerSpellId;
if (triggerRec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
return false;
if (m.triggerSchool >= 0 &&
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
return false;
return true;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
case MOD_REFRESH:
if (e.durationMs > 0)
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
else
e.used = false; // shaved to/past now → server removes it
}
break;
case MOD_REMOVE:
e.used = false;
break;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
// own, so they'd otherwise be dropped.
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
const uint64_t *targets, int numTargets) {
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
return;
const uint8_t *triggerRec =
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
const uint32_t now = NowMs();
for (int r = 0; r < g_modCount; ++r) {
const DurationMod &m = g_mods[r];
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
// but PROC-applied ones (Shadow Weaving) enter the cache via the
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
// caster), so accept those too and attribute them to this trigger
// caster (also gives them a sourceUnit).
if (e.casterGuid != 0 && e.casterGuid != caster)
continue;
if (!AffectedMatches(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
continue;
if (e.casterGuid == 0)
e.casterGuid = caster;
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
}
}
}
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
DurationMod &m = g_mods[i];
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
m.op = op;
m.valueMs = valueMs;
return true;
}
}
if (g_modCount >= kMaxMods)
return false;
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
affectedFamily, affectedMask, affectedIcon, op, valueMs};
return true;
}
int OpFromString(const char *s) {
if (s == nullptr)
return -1;
if (_stricmp(s, "refresh") == 0)
return MOD_REFRESH;
if (_stricmp(s, "reduce") == 0)
return MOD_REDUCE;
if (_stricmp(s, "set") == 0)
return MOD_SET;
if (_stricmp(s, "remove") == 0)
return MOD_REMOVE;
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
// (trigger, family, flags, icon) tuple replaces its op/value.
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsString(L, 5)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
"valueSeconds])");
return 0;
}
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
const int op = OpFromString(Game::Lua::ToString(L, 5));
const int32_t valueMs =
Game::Lua::IsNumber(L, 6)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
: 0;
if (op < 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, family, mask,
icon, op, valueMs));
return 1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
// by SpellFamilyName + school index instead of an exact spellID — one rule
// covers every rank / server-added spell of a class's school. triggerSchool
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
// refresh the target's Shadow Vulnerability.
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
"affectedIcon, op[, valueSeconds])");
return 0;
}
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
const int op = OpFromString(Game::Lua::ToString(L, 6));
const int32_t valueMs =
Game::Lua::IsNumber(L, 7)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
: 0;
if (op < 0 || tfamily == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
family, mask, icon, op, valueMs));
return 1;
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
&Script_RegisterAuraDurationModifier);
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
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 +535,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};
@@ -236,6 +586,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (caster == Unit::Identity::PlayerGuid())
Totem::Tracker::OnPlayerSpellGo(spellId);
// Mirror server-side duration edits the client is never told about
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
@@ -252,6 +607,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 +644,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 +790,55 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
return n;
}
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
if (guid == 0 || auraArray == nullptr)
return;
const int total = Offsets::UNIT_AURA_TOTAL;
const int buffCount = Offsets::UNIT_AURA_BUFF_COUNT;
const uint32_t now = NowMs();
GroupSnapshot *snap = nullptr;
for (auto &s : g_groupSnaps)
if (s.used && s.guid == guid) {
snap = &s;
break;
}
if (snap == nullptr) {
// First sight of this member — record a baseline without stamping;
// their current auras have unknown age.
GroupSnapshot *dst = nullptr;
for (auto &s : g_groupSnaps)
if (!s.used) {
dst = &s;
break;
}
if (dst == nullptr) { // full — evict the least-recently-polled
dst = &g_groupSnaps[0];
for (auto &s : g_groupSnaps)
if (s.touchMs < dst->touchMs)
dst = &s;
}
dst->guid = guid;
dst->used = true;
dst->touchMs = now;
for (int i = 0; i < total; ++i)
dst->ids[i] = auraArray[i];
return;
}
// Stamp a guess for every spell ID present now but absent from the prior
// snapshot (a genuine appearance), then refresh the snapshot.
for (int slot = 0; slot < total; ++slot) {
const uint16_t id = auraArray[slot];
if (id == 0 || SnapshotHasId(*snap, id))
continue;
StampGroupGuess(guid, id, slot < buffCount ? KIND_HELPFUL : KIND_HARMFUL,
now);
}
for (int i = 0; i < total; ++i)
snap->ids[i] = auraArray[i];
snap->touchMs = now;
}
} // namespace Aura::Source
+15
View File
@@ -74,4 +74,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// Feeds the cache from an out-of-range group member's aura spell-ID array
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
// carries the spell IDs and the server sends it promptly when an aura is
// added/removed. So a spell ID that newly *appears* here did so ~one server
// tick + latency after the real application: we stamp a best-effort
// `now + base duration` expiration (base only — no remote caster mods, no
// caster recorded). Auras already present the first time we see a member have
// unknown age and are NOT stamped; only genuine absent->present transitions
// are. Idempotent within a frame; call it before enumerating the array so a
// just-appeared aura carries its guess on the same poll. The Store guard keeps
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
} // namespace Aura::Source
+83
View File
@@ -0,0 +1,83 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
// exist whenever the DLL is injected, even if the addon is disabled. The
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
// Lua-side in the addon, mirroring retail's Mixin.lua.
//
// Behaviour matches the canonical Lua implementation exactly:
// Mixin(object, ...) -- shallow-copy every key/value from each table
// argument onto `object`; returns `object`.
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
// survive for the next Next() iteration.
Game::Lua::PushValue(L, -2); // key copy
Game::Lua::PushValue(L, -2); // value copy
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
}
}
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
return 0; // unreachable
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
int __fastcall Script_CreateFromMixins(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // new object at index top + 1
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
}
// Pure table helpers — register on both the in-game and glue states so they're
// always present (matching the "always available" intent).
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::Mixin
+1
View File
@@ -265,6 +265,7 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
+170
View File
@@ -0,0 +1,170 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
// Windows clipboard and return the number of bytes copied. Vanilla has no
// clipboard access at all (Blizzard never exposed one), so this is a pure
// ClassicAPI addition backed by the Win32 clipboard API.
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
//
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include <string>
#include <windows.h>
namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
++i;
break; // the |t is dropped by the 't' case on the next iteration
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
return out;
}
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
// success. Does not free the allocation on success — ownership passes to the
// clipboard.
bool SetClipboardUtf8(const char *data, int byteLen) {
if (!OpenClipboard(nullptr))
return false;
bool ok = false;
if (EmptyClipboard()) {
// Empty string still writes a valid (empty) clipboard entry.
const int wlen =
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
if (h != nullptr) {
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
if (dst != nullptr) {
if (wlen > 0)
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
ok = true; // clipboard owns h now
}
if (!ok)
GlobalFree(h);
}
}
CloseClipboard();
return ok;
}
int __fastcall Script_CopyToClipboard(void *L) {
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
return 0; // unreachable
}
const char *text = Game::Lua::ToString(L, 1);
const unsigned len = Game::Lua::StrLen(L, 1);
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
int copiedLen;
if (removeMarkup) {
const std::string stripped = StripMarkup(text, len);
copiedLen = static_cast<int>(stripped.size());
SetClipboardUtf8(stripped.data(), copiedLen);
} else {
copiedLen = static_cast<int>(len);
SetClipboardUtf8(text, copiedLen);
}
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
}
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Clipboard::Copy
+292
View File
@@ -0,0 +1,292 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_ColorUtil` — the modern color-space + text-color-code helpers, backported
// to 1.12. Every one is pure math / string formatting (no engine state), so
// this is a plain reimplementation rather than a hook.
//
// Conventions verified in-game against a retail-family client that ships
// C_ColorUtil natively:
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
// no alpha channel).
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
//
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
// optional a field is honored (default 1.0) though retail's colorRGB type
// omits it — for the documented no-alpha case both give ff.
#include "Game.h"
#include <cmath>
#include <cstdio>
#include <string>
namespace Color::Util {
namespace {
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
int ToByte(double v) {
const double c = Clamp01(v);
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
}
double Max3(double a, double b, double c) {
double m = a > b ? a : b;
return m > c ? m : c;
}
double Min3(double a, double b, double c) {
double m = a < b ? a : b;
return m < c ? m : c;
}
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
// --- Pure conversions --------------------------------------------------
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
v = mx;
s = (mx <= 0.0) ? 0.0 : d / mx;
if (d <= 0.0) { // achromatic
h = -1.0;
return;
}
if (mx == r)
h = 60.0 * std::fmod((g - b) / d, 6.0);
else if (mx == g)
h = 60.0 * ((b - r) / d + 2.0);
else
h = 60.0 * ((r - g) / d + 4.0);
if (h < 0.0)
h += 360.0;
}
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
r = g = b = v;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double hh = h / 60.0;
const int i = static_cast<int>(std::floor(hh));
const double f = hh - i;
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * f);
const double t = v * (1.0 - s * (1.0 - f));
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
ho = h; // hue (incl. -1) passes through unchanged
l = v * (1.0 - sv / 2.0);
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
}
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
ho = h;
v = l + sl * MinLuma(l);
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
}
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic
r = g = b = l;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
const double hh = h / 60.0;
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
const double m = l - c / 2.0;
double r1, g1, b1;
switch (static_cast<int>(std::floor(hh)) % 6) {
case 0: r1 = c; g1 = x; b1 = 0.0; break;
case 1: r1 = x; g1 = c; b1 = 0.0; break;
case 2: r1 = 0.0; g1 = c; b1 = x; break;
case 3: r1 = 0.0; g1 = x; b1 = c; break;
case 4: r1 = x; g1 = 0.0; b1 = c; break;
default: r1 = c; g1 = 0.0; b1 = x; break;
}
r = r1 + m;
g = g1 + m;
b = b1 + m;
}
// --- Lua glue ----------------------------------------------------------
// Read arg `n` as a number, or raise the usage error.
double Arg(void *L, int n, const char *usage) {
if (!Game::Lua::IsNumber(L, n)) {
Game::Lua::Error(L, "%s", usage);
return 0.0; // unreachable
}
return Game::Lua::ToNumber(L, n);
}
// Read a numeric field from the color table at (absolute, positive) index
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
double ColorField(void *L, int idx, const char *key, double dflt) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, idx);
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
Game::Lua::SetTop(L, -2); // pop the value
return v;
}
// "AARRGGBB" from a color table at `idx`, alpha default ff.
std::string HexFromColor(void *L, int idx) {
const int a = ToByte(ColorField(L, idx, "a", 1.0));
const int r = ToByte(ColorField(L, idx, "r", 0.0));
const int g = ToByte(ColorField(L, idx, "g", 0.0));
const int b = ToByte(ColorField(L, idx, "b", 0.0));
char buf[9];
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
return std::string(buf, 8);
}
int __fastcall Script_ConvertRGBToHSV(void *L) {
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
double h, s, v;
RgbToHsv(r, g, b, h, s, v);
Game::Lua::PushNumber(L, h);
Game::Lua::PushNumber(L, s);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSVToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
double r, g, b;
HsvToRgb(h, s, v, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_ConvertHSVToHSL(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
double ho, sl, l;
HsvToHsl(h, s, v, ho, sl, l);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sl);
Game::Lua::PushNumber(L, l);
return 3;
}
int __fastcall Script_ConvertHSLToHSV(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
double ho, sv, v;
HslToHsv(h, s, l, ho, sv, v);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sv);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSLToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
double r, g, b;
HslToRgb(h, s, l, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_GenerateTextColorCode(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
return 0; // unreachable
}
const std::string hex = HexFromColor(L, 1);
Game::Lua::PushString(L, hex.c_str());
return 1;
}
int __fastcall Script_WrapTextInColor(void *L) {
const char *text = Game::Lua::ToString(L, 1);
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
return 0; // unreachable
}
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
int __fastcall Script_WrapTextInColorCode(void *L) {
const char *text = Game::Lua::ToString(L, 1);
const char *code = Game::Lua::ToString(L, 2);
if (text == nullptr || code == nullptr) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
return 0; // unreachable
}
std::string out = std::string("|c") + code + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
&Script_ConvertRGBToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
&Script_ConvertHSVToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
&Script_ConvertHSVToHSL);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
&Script_ConvertHSLToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
&Script_ConvertHSLToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
&Script_GenerateTextColorCode);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
&Script_WrapTextInColor);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
&Script_WrapTextInColorCode);
}
// Pure helpers — useful on both the in-game and glue states.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Color::Util
+153
View File
@@ -26,6 +26,12 @@
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `region:IsMouseOver([top, bottom, left, right])` — cursor-in-rect
// test with optional per-edge offsets, also on the REGION registry.
// - `region:GetRect()` — left, bottom, width, height, composed from the
// engine's Region getters. Also on the REGION registry.
// - `region:IsDragging()` — true while the region is the frame currently
// being moved/sized. Reads the engine's global drag target.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
@@ -34,6 +40,9 @@
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
// - `frame:GetEffectiveAlpha()` — own alpha × the parent chain's, the
// product WoW composites at render time. Frame-scoped (matches
// GetAlpha); walks the parents via the engine's own getters.
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
@@ -83,6 +92,119 @@ int __fastcall Script_GetSize(void *L) {
return 2;
}
// ---- IsMouseOver (Region base — all region types) --------------------------
// `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])` —
// true when the cursor is within the region's rect, each edge optionally
// shifted by the matching offset (added to that edge, modern semantics:
// +top/-bottom/-left/+right enlarges). Vanilla addons have always done this
// in Lua as MouseIsOver(); we register it as the native Region method later
// clients expose.
//
// The math mirrors the engine exactly: GetLeft/Right/Top/Bottom (Region
// registry, all types) and GetCursorPosition both return values scaled by the
// same k = FUN_0041ad70()*DAT_007ffd68 factor, but the rect getters also
// divide by the region's effective scale — so dividing the cursor by that same
// effective scale (read from obj+0x7C, the field GetLeft/GetEffectiveScale
// themselves read) puts both in the region's logical space. A getter returns
// nil for an unpositioned region → we report false.
// Calls a Region getter that returns one number; false if it pushed nil/none.
bool CallRegionNumber(void *L, uintptr_t fn, double *out) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(fn, L); // (self, value | nil)
if (!Game::Lua::IsNumber(L, 2))
return false;
*out = Game::Lua::ToNumber(L, 2);
return true;
}
int __fastcall Script_IsMouseOver(void *L) {
// Offsets first — the stack gets reshaped by the delegated getters.
const double offTop = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0.0;
const double offBottom = Game::Lua::IsNumber(L, 3) ? Game::Lua::ToNumber(L, 3) : 0.0;
const double offLeft = Game::Lua::IsNumber(L, 4) ? Game::Lua::ToNumber(L, 4) : 0.0;
const double offRight = Game::Lua::IsNumber(L, 5) ? Game::Lua::ToNumber(L, 5) : 0.0;
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
double left, right, top, bottom;
if (effScale == 0.0f ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom)) {
Game::Lua::PushBool(L, false); // no resolved rect
return 1;
}
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
const double x = Game::Lua::ToNumber(L, 1) / effScale;
const double y = Game::Lua::ToNumber(L, 2) / effScale;
left += offLeft;
right += offRight;
top += offTop;
bottom += offBottom;
const bool inside = x >= left && x <= right && y >= bottom && y <= top;
Game::Lua::PushBool(L, inside);
return 1;
}
// ---- GetRect (Region base — all region types) ------------------------------
// `region:GetRect()` → left, bottom, width, height. Composed from the
// engine's own Region getters (so all UI-scale conversion is its code).
// Matches retail: returns nothing for a region with no resolved rect —
// gated on GetLeft, which pushes nil when the position isn't resolved (a
// size-only region with no anchor has width/height but no rect).
int __fastcall Script_GetRect(void *L) {
double left, bottom, width, height;
if (!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left))
return 0;
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETWIDTH, &width);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETHEIGHT, &height);
Game::Lua::SetTop(L, 1);
Game::Lua::PushNumber(L, left);
Game::Lua::PushNumber(L, bottom);
Game::Lua::PushNumber(L, width);
Game::Lua::PushNumber(L, height);
return 4;
}
// ---- IsDragging (Region base — all region types) ---------------------------
// `region:IsDragging()` — true while this region is the frame currently
// being moved/sized (StartMoving/StartSizing active, before
// StopMovingOrSizing). The engine tracks a single drag target in the global
// UI context at +0xCFC; StopMovingOrSizing itself gates on
// `context+0xCFC == self`, which is exactly this predicate. Reading the
// global context (rather than self+0xA0) keeps it correct for non-frame
// regions — a texture/fontstring self can never match the frame stored
// there, so it reports false, as it should.
int __fastcall Script_IsDragging(void *L) {
void *self = Game::Lua::ResolveObject(L, 1);
void *ctx = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *dragTarget =
ctx != nullptr
? *reinterpret_cast<void *const *>(
reinterpret_cast<const uint8_t *>(ctx) +
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
: nullptr;
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
return 1;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
@@ -95,6 +217,33 @@ int __fastcall Script_SetShown(void *L) {
return 0;
}
// ---- GetEffectiveAlpha (Frame) ---------------------------------------------
// `frame:GetEffectiveAlpha()` — the frame's own alpha times every
// ancestor's alpha, i.e. the product WoW composites at render time (fading
// UIParent fades all children). Vanilla exposes only GetAlpha (own alpha);
// we walk self → parent → … via the engine's own GetAlpha (Frame) and
// GetParent (Region), so no field offsets or scale math are re-derived. The
// iteration cap is a defensive guard against a pathological chain (WoW frame
// trees are shallow; the loop normally ends when GetParent returns nil at
// the top of the tree).
int __fastcall Script_GetEffectiveAlpha(void *L) {
Game::Lua::SetTop(L, 1); // (obj) = self
double eff = 1.0;
for (int guard = 0; guard < 128; ++guard) {
CallScript(Offsets::FUN_SCRIPT_FRAME_GETALPHA, L); // (obj, alpha)
eff *= Game::Lua::ToNumber(L, 2);
Game::Lua::SetTop(L, 1); // (obj)
CallScript(Offsets::FUN_SCRIPT_REGION_GETPARENT, L); // (obj, parent|nil)
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL)
break;
Game::Lua::Remove(L, 1); // drop obj → (parent) becomes index 1
}
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, eff);
return 1;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
@@ -274,6 +423,9 @@ const Game::HookAutoRegister _setPointHook{
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
{"IsMouseOver", &Script_IsMouseOver},
{"GetRect", &Script_GetRect},
{"IsDragging", &Script_IsDragging},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
@@ -282,6 +434,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Glue` session helpers.
//
// `C_Glue.IsFirstLoadThisSession()` — glue-only. Returns true only while the
// FIRST glue screen since the process launched is showing, and false on
// every return to glue after a world login (a genuine world→glue logout;
// login↔character-select stays within the first glue session). Retail uses
// it so the login UI runs one-time startup (intro cinematic, news) only on
// the initial launch, not on each relog.
//
// Backed by a process-static boot counter, no engine state needed: the glue
// registration callback fires exactly once per glue boot
// (FUN_LOAD_GLUE_SCRIPT_FUNCTIONS — initial launch + each world→glue return),
// so the counter equals "how many glue screens this session" and the first
// one is boot #1. The glue Lua state is recreated per boot, so C_Glue is
// re-registered each time; the counter is process-static and survives that.
//
// `C_Glue.IsOnGlueScreen()` — true when a GlueXML screen is showing (no
// character logged in), false in the world. Registered on BOTH Lua states,
// and the answer is a compile-time constant per state: the glue and in-game
// Lua states are mutually exclusive (glue exists only when no character is in
// the world), so the glue registration returns true and the in-game one
// returns false — no runtime flag to consult. Available in both states so
// shared code that runs in either environment can branch on it.
#include "Game.h"
namespace Glue::Session {
namespace {
// Glue-screen boots since process start. 1 during the very first glue screen.
int g_glueBootCount = 0;
int __fastcall Script_IsFirstLoadThisSession(void *L) {
Game::Lua::PushBool(L, g_glueBootCount <= 1);
return 1;
}
int __fastcall Script_IsOnGlueScreen_True(void *L) {
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsOnGlueScreen_False(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
void RegisterGlue() {
++g_glueBootCount; // one glue boot per callback invocation
Game::Lua::RegisterTableFunction("C_Glue", "IsFirstLoadThisSession",
&Script_IsFirstLoadThisSession);
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_True);
}
void RegisterInGame() {
// In-world: only IsOnGlueScreen makes sense (and it's always false).
// IsFirstLoadThisSession is a glue concept, so it's not registered here.
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_False);
}
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
} // namespace
} // namespace Glue::Session
+13 -2
View File
@@ -21,7 +21,7 @@
namespace Item::Arg {
Resolved Resolve(void *L, int idx) {
Resolved out{0, nullptr};
Resolved out{0, 0, nullptr};
if (Game::Lua::IsNumber(L, idx)) {
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
return out;
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
return out;
}
if (const char *m = std::strstr(s, "item:")) {
out.itemID = std::atoi(m + 5);
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
out.suffix = std::atoi(p + 1);
break;
}
}
return out;
}
// Plain string — try numeric, otherwise expose as a name for
+1
View File
@@ -22,6 +22,7 @@ namespace Item::Arg {
// can ignore `name` and treat `itemID == 0` as "no usable input".
struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
};
+15 -4
View File
@@ -259,7 +259,8 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Item::Arg::Resolved arg = Item::Arg::Resolve(L, 1);
const int itemID = arg.itemID;
if (itemID <= 0)
return 0;
@@ -288,14 +289,24 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
// Apply the link's random suffix ("... of the Owl") to both the name and
// the reconstructed link when the input carried one (arg.suffix); with no
// suffix these reduce to the base name / plain `item:N:0:0:0` link.
char suffixedName[128];
const char *displayName =
Item::Link::NameFromIDSuffix(static_cast<uint32_t>(itemID), arg.suffix,
suffixedName, sizeof(suffixedName))
? suffixedName
: name;
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
const bool haveLink = Item::Link::BasicFromIDSuffix(static_cast<uint32_t>(itemID),
arg.suffix, link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
Game::Lua::PushString(L, PushedOrEmpty(displayName)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
+31 -7
View File
@@ -30,6 +30,10 @@ using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Inner name builder, no CGItem needed: (out, outSize, itemID, suffixID).
using BuildNameFromID_t = void(__fastcall *)(char *out, unsigned outSize,
uint32_t itemID, int suffixID);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -63,25 +67,45 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildNameFromID_t>(
Offsets::FUN_ITEM_BUILD_NAME_FROM_ID);
fn(out, static_cast<unsigned>(outSize), itemID, suffixID);
return out[0] != '\0';
}
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr)
return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return false;
char name[128];
if (!NameFromIDSuffix(itemID, suffixID, name, sizeof(name))) {
// Suffix builder yielded nothing (e.g. suffix row missing) — fall back
// to the base record name so we at least produce a valid link.
const char *base = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (base == nullptr || *base == '\0')
return false;
std::snprintf(name, sizeof(name), "%s", base);
}
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
"%s|Hitem:%u:0:%d:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffixID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
return BasicFromIDSuffix(itemID, 0, out, outSize);
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+14
View File
@@ -51,4 +51,18 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
// Writes the suffix-decorated display name for `(itemID, suffixID)` into
// `out` WITHOUT a live item instance — the by-string analog of
// NameFromCGItem. `suffixID` is the 3rd `item:` field (random property /
// suffix); 0 = base name. Applies the "... of the Owl" suffix via the
// engine's inner builder (FUN_ITEM_BUILD_NAME_FROM_ID) when the item is
// cached and the suffix is a valid ItemRandomProperties row. Returns false
// (out empty) if the item isn't cached or has no name — warm and retry.
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
// Like BasicFromItemID but preserves the random `suffixID` in both the
// `item:` payload (`item:N:0:suffix:0`) and the bracketed, suffix-decorated
// name. `suffixID` 0 reduces exactly to BasicFromItemID.
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
} // namespace Item::Link
+20 -1
View File
@@ -94,8 +94,27 @@ static int __fastcall Script_C_Item_GetItemName(void *L) {
// for `itemID`, firing a background cache fill on miss. Accepts the
// same input shapes as `GetItemInfoInstant` (number, `"item:NNN..."`
// string, or any string containing a parseable `item:NN` chunk).
//
// When the input is an item link carrying a random suffix
// (`item:12022:0:769:0`), the returned name is suffix-decorated
// ("Iridium Chain of the Owl") — the 3rd `item:` field is parsed and
// applied via the engine's own name builder, so it matches the global
// `GetItemInfo`'s name. A bare itemID (number or `item:N`) has no suffix
// and yields the base name.
static int __fastcall Script_C_Item_GetItemNameByID(void *L) {
return PushNameForItemID(L, Item::Arg::ResolveItemID(L, 1));
const Item::Arg::Resolved r = Item::Arg::Resolve(L, 1);
if (r.itemID <= 0)
return 0;
char buf[128];
if (Item::Link::NameFromIDSuffix(static_cast<uint32_t>(r.itemID), r.suffix,
buf, sizeof(buf))) {
Game::Lua::PushString(L, buf);
return 1;
}
// Not cached yet — fire a background fill and return nil (same miss
// behavior as the base-name path).
Item::Data::WarmCache(static_cast<uint32_t>(r.itemID));
return 0;
}
static void RegisterLuaFunctions() {
+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();
+19 -8
View File
@@ -140,20 +140,31 @@ bool Resolve() {
namespace {
// 4th arg is the GUID high dword. Ghidra types it `float` from usage, but
// it's a plain dword on the stack (the engine assigns it the current
// selection's high dword when 0); `unsigned` puts the correct 4 bytes there.
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
unsigned targetGuidLo, unsigned targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
void DispatchSlot(int slot0Based, int bookType, uint64_t targetGuid) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
// GUID 0 = no implicit target (engine uses current selection / placement).
// A real GUID casts a unit-target spell straight at that unit.
dispatch(static_cast<unsigned>(slot0Based), bookType,
static_cast<unsigned>(targetGuid),
static_cast<unsigned>(targetGuid >> 32));
}
} // namespace
bool DispatchSpellCast(int spellID) {
bool IsPlacementActive() {
return *reinterpret_cast<const uint32_t *>(
Offsets::VAR_SPELL_PLACEMENT_STATE) != 0;
}
bool DispatchSpellCast(int spellID, uint64_t targetGuid) {
if (spellID <= 0)
return false;
@@ -167,11 +178,11 @@ bool DispatchSpellCast(int spellID) {
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
DispatchSlot(slot1 - 1, bookType, targetGuid);
return true;
}
bool DispatchSpellCastByName(const char *name) {
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid) {
if (name == nullptr || *name == '\0')
return false;
@@ -185,7 +196,7 @@ bool DispatchSpellCastByName(const char *name) {
if (slot < 0)
return false; // not in the player's spellbook
DispatchSlot(slot, bookType);
DispatchSlot(slot, bookType, targetGuid);
return true;
}
+17 -2
View File
@@ -13,8 +13,16 @@
#pragma once
#include <cstdint>
namespace Spell::AtCursor {
// True while the engine has an in-flight targeting placement of any kind
// (ground reticle, unit/item/corpse select). `VAR_SPELL_PLACEMENT_STATE != 0`.
// Used by `CastAtUnit` to tell a unit-target spell that fired immediately
// (no placement) from a ground spell that's waiting for a location.
bool IsPlacementActive();
// Resolves the engine's in-flight ground-target placement at the
// player's current cursor world position. Intended to be called
// immediately after initiating a ground-target cast (spell or item)
@@ -51,7 +59,14 @@ bool CommitAtCoords(const float coords[3]);
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
//
// `targetGuid` is the implicit cast target: `0` = the engine's default
// (current selection, or ground placement for AoE); a unit's GUID makes
// a unit-target/normal spell fire directly on that unit (the engine
// substitutes it exactly where it would the current target). Ground
// spells ignore it and still enter placement. `CastAtUnit` passes the
// resolved unit's GUID; `CastAtCursor` leaves it 0.
bool DispatchSpellCast(int spellID, uint64_t targetGuid = 0);
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid = 0);
} // namespace Spell::AtCursor
+47 -26
View File
@@ -11,33 +11,46 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.CastAtUnit(spellID, unit)` — cast a ground-target spell at
// the given unit's feet, bypassing the manual click on the AoE
// reticle. `CastAtUnit(spellID, "player")` drops it at the player's
// position, `"target"` at the target's, etc.
// `C_Spell.CastAtUnit(spellIDOrName, unit)` — cast a spell at the given
// unit, whatever its target type. One primitive covering both:
//
// Same two-step shape as `C_Spell.CastAtCursor`, but the placement is
// committed at the unit's world position instead of the cursor
// raycast:
// - **Ground-target spells** (Blizzard, Flamestrike, Rain of Fire…) are
// placed at the unit's feet — the reticle is committed at the unit's
// world position, no manual click.
// - **Unit-target / normal spells** (Frostbolt, Healing Touch, a buff…)
// fire directly on the unit — its GUID is fed to the engine's cast
// dispatcher as the implicit target, so no current-target juggling and
// no "select target" reticle.
//
// 1. `Spell::AtCursor::DispatchSpellCast(spellID)` initiates the
// cast; the engine enters placement mode for a ground-target
// spell.
// 2. Read the unit's world position (vtable `GetPosition`) and
// `Spell::AtCursor::CommitAtCoords()` commits the placement
// there.
// Mechanism: resolve the token once to the unit object, read its position
// and GUID, then dispatch the cast WITH that GUID
// (`Spell::AtCursor::DispatchSpellCast(spell, guid)`). The engine's
// dispatcher substitutes the GUID exactly where it would the current
// selection, so a unit-target spell fires immediately at that unit. A
// ground spell ignores the GUID and enters placement mode instead — so we
// branch on `IsPlacementActive()`:
//
// Returns `true` when the placement was committed at the unit; `false`
// when the spell isn't ground-target (placement cancelled), the spell
// isn't in the spellbook, or the unit has no resolvable position. The
// cast itself still proceeds for non-ground spells, just with no
// implicit target — matching `CastAtCursor`'s semantics.
// - No placement after dispatch → the cast already went out on the unit
// (unit-target / normal). Success.
// - Ground placement active → commit at the unit's feet. Success.
// - Other placement still active → the engine didn't accept the unit as a
// target (e.g. wrong faction / out of range); `CommitAtCoords` cancels
// it. Failure.
//
// Returns `true` when the spell was cast at the unit; `false` when the
// spell isn't in the spellbook, the unit has no resolvable position, or the
// unit wasn't a valid target. A genuinely unrecognized token string raises
// the engine's standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.
#include "Game.h"
#include "spell/AtCursor.h"
#include "spell/MacroPrimarySpell.h"
#include "unit/Identity.h"
#include "unit/Position.h"
#include <cstdint>
namespace Spell::AtUnit {
namespace {
@@ -53,27 +66,35 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
}
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
// bad/absent unit doesn't leave the engine sitting in placement
// mode. (A genuinely unrecognized token string raises the engine's
// standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.)
// Resolve the unit once → object, then read position + GUID from it.
// Fail fast so a bad/absent unit doesn't leave the engine mid-cast.
void *unitObj = Unit::Position::ResolveToken(token);
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
if (unitObj == nullptr || !Unit::Position::Read(unitObj, pos)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t targetGuid = Unit::Identity::GuidForObject(unitObj);
const bool dispatched =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
static_cast<int>(Game::Lua::ToNumber(L, 1)), targetGuid)
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1),
targetGuid);
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
// Unit-target / normal spells fired straight at the GUID — no placement.
// Ground spells are now sitting in placement mode; commit at the unit's
// feet (CommitAtCoords cancels + fails for a non-ground placement, i.e.
// an unaccepted unit target).
if (!Spell::AtCursor::IsPlacementActive()) {
Game::Lua::PushBool(L, true);
return 1;
}
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
Game::Lua::PushBool(L, placed);
return 1;
+76
View File
@@ -0,0 +1,76 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellEffectMechanics(spellID)` -> { m1, m2, m3 }
//
// Returns a spell's three per-effect mechanic ids (Spell.dbc
// `EffectMechanic[3]`) as a 1-based array table, or nil for an invalid /
// out-of-range spell ID. Each entry is a standard WoW SpellMechanic id
// (0 = that effect carries no mechanic); the numbering matches
// C_Spell.GetSpellMechanicByID (1 = Charm, 7 = Root, 12 = Stun,
// 15 = Bleed, 17 = Polymorph, ...).
//
// Complements GetSpellMechanicByID, which reads only the SPELL-level
// `Mechanic` field (+0x14). Vanilla frequently stores the meaningful
// mechanic on an *effect* rather than the spell — periodic damage such
// as bleeds is the common case — so effect-mechanic-aware callers (e.g.
// bleed classification for immunity tracking) need this array too:
//
// local em = C_Spell.GetSpellEffectMechanics(spellID)
// if em then for i = 1, 3 do if em[i] == 15 then --[[ bleed ]] end end end
//
// Reads Spell.dbc directly, so it covers every spell the client knows —
// not just the player's spellbook — with no caching or network round-trip
// (Spell.dbc is resident from boot).
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Spell::EffectMechanic {
namespace {
int __fastcall Script_GetSpellEffectMechanics(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil for invalid / out-of-range spell IDs
auto *mechanics = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_MECHANIC);
// Fixed 1-based array of all three effects (0 = effect has no mechanic),
// matching the raw EffectMechanic[3] layout so callers can index by slot.
Game::Lua::NewTable(L);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // key
Game::Lua::PushNumber(L, static_cast<double>(mechanics[i])); // value
Game::Lua::SetTable(L, -3);
}
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellEffectMechanics",
&Script_GetSpellEffectMechanics);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::EffectMechanic
+33 -20
View File
@@ -443,34 +443,46 @@ static int __fastcall Script_C_IsSpellPassive(void *L) {
// arrays and not in the talent tree).
// - Same shape modern WoW (5.4.8+) uses for the same function — see
// `[0x011C25D8]` in 5.4.8's `Wow.exe`.
// Shared read of the engine's known-spell bitmap (the same bit the helper
// at `0x0060C740` consults): true iff the local player knows `spellID`.
// Bounds-checked; false for out-of-range IDs or before the bitmap exists.
static bool PlayerKnowsSpell(int spellID) {
if (spellID < 1)
return false;
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t mask = 1u << (spellID & 31);
return (bitmap[spellID >> 5] & mask) != 0;
}
static int __fastcall Script_IsPlayerSpell(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: IsPlayerSpell(spellID)");
return 0;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID < 1) {
Game::Lua::PushBool(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, PlayerKnowsSpell(spellID));
return 1;
}
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
// `CanDualWield()` — true if the local player can equip a weapon in the off
// hand. The server tracks this as a plain bool (`Player::m_canDualWield`)
// flipped on only by `SPELL_EFFECT_DUAL_WIELD` (effect 40); no class has it
// innately. Verified against the client Spell.dbc: exactly ONE spell carries
// that effect — 674 "Dual Wield", the trained warrior/rogue/hunter passive —
// and it lands in the known-spell bitmap like any learned spell. So there's
// no separate client-visible capability flag to read; `IsPlayerSpell(674)`
// is the exact equivalent of the server's `CanDualWield()`.
static constexpr int kDualWieldSpellID = 674;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t mask = 1u << (spellID & 31);
const bool isKnown = (bitmap[spellID >> 5] & mask) != 0;
Game::Lua::PushBoolean(L, isKnown);
static int __fastcall Script_CanDualWield(void *L) {
Game::Lua::PushBoolean(L, PlayerKnowsSpell(kDualWieldSpellID));
return 1;
}
@@ -623,6 +635,7 @@ static void RegisterLuaFunctions() {
&Script_FindSpellBookSlotByID);
Game::Lua::RegisterGlobalFunction("IsPassiveSpell", &Script_IsPassiveSpell);
Game::Lua::RegisterGlobalFunction("IsPlayerSpell", &Script_IsPlayerSpell);
Game::Lua::RegisterGlobalFunction("CanDualWield", &Script_CanDualWield);
Game::Lua::RegisterGlobalFunction("IsSpellKnown", &Script_IsSpellKnown);
Game::Lua::RegisterGlobalFunction("IsHarmfulSpell", &Script_IsHarmfulSpell);
Game::Lua::RegisterGlobalFunction("IsHelpfulSpell", &Script_IsHelpfulSpell);
+77
View File
@@ -0,0 +1,77 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include <cmath>
#include <vector>
namespace Table::Count {
// `numTableNodes, numArrayNodes, maxArrayIndex = table.count(tbl)` — a modern
// WoW diagnostic (added retail 11.2.5) reporting how a table is populated.
// These are COUNTS of live entries, not the table's allocated capacity:
//
// numTableNodes total number of key/value pairs.
// numArrayNodes count of pairs whose key is an integer in [1..numTableNodes]
// (the "looks like a contiguous array part" heuristic).
// maxArrayIndex the largest positive integral key (>= 1), or 0 if none.
// Verified against retail: negative/zero integral keys do
// NOT count ({[-3]=x} -> 1, 0, 0).
//
// Pure iteration — no Lua-internal struct layout needed. Note we only ever
// read a key via ToNumber, never ToString: lua_tostring on a NUMERIC key
// rewrites it to a string in place and corrupts lua_next's traversal.
static int __fastcall Script_table_count(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: table.count(table)");
return 0;
}
std::vector<double> integralKeys;
int total = 0;
double maxKey = 0.0; // largest positive integral key seen; 0 = none
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, 1) != 0) {
++total;
// key at -2, value at -1.
if (Game::Lua::Type(L, -2) == Game::Lua::TYPE_NUMBER) {
const double k = Game::Lua::ToNumber(L, -2);
if (k == std::floor(k)) { // integral key
integralKeys.push_back(k);
if (k >= 1.0 && k > maxKey) // only positive keys count
maxKey = k;
}
}
Game::Lua::SetTop(L, -2); // pop value, keep key for the next Next()
}
int numArray = 0;
for (double k : integralKeys)
if (k >= 1.0 && k <= static_cast<double>(total))
++numArray;
Game::Lua::PushNumber(L, static_cast<double>(total));
Game::Lua::PushNumber(L, static_cast<double>(numArray));
Game::Lua::PushNumber(L, maxKey);
return 3;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("table", "count", &Script_table_count);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Table::Count
+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
+115
View File
@@ -0,0 +1,115 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// UPDATE_MOUSEOVER_UNIT loss-fire polyfill.
//
// Vanilla fires `UPDATE_MOUSEOVER_UNIT` only when a mouseover is *gained*
// (the engine's `FUN_SET_MOUSEOVER_UNIT` fires it inline in the
// friendly/hostile-unit tooltip case). When the cursor leaves the unit,
// the engine clears the mouseover GUID but fires nothing — so an addon
// that reacted to `UnitExists("mouseover")` becoming true never gets told
// when it becomes false. Retail fires the event for both gain and loss.
//
// We close the gap by co-hooking the single set/clear chokepoint (it is
// the ONLY writer of the mouseover GUID globals, so every gain / loss /
// change flows through it): resolve whether the mouseover was a *unit*
// before the original, then after, and fire `UPDATE_MOUSEOVER_UNIT` when
// it went from a unit to a non-unit.
//
// The unit-vs-non-unit test matters: the mouseover GUID also holds
// gameobjects (and items), but the engine only fires the *gain* event for
// units — the gameobject/item cases never fire it. So a naive
// "GUID present -> absent" test spuriously fires when you move off a
// gameobject that never fired a gain. Gating on "old was a unit, new
// isn't" fixes that while still matching retail's other cases:
//
// * unit -> nothing : fire (retail fires)
// * unit -> gameobject : fire (retail fires — mouseover unit lost)
// * gameobject -> nothing: no (bug fixed; no gain ever fired)
// * X -> unit : engine already fired inline; we don't (new is
// a unit, so our `!newIsUnit` guard is false —
// no double-fire on gain or unit->unit change)
//
// The event carries no arguments in vanilla (same as retail); handlers
// read `UnitExists("mouseover")` — which is false at fire time on the loss
// path, exactly matching retail semantics.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Mouseover {
namespace {
// `FUN_SET_MOUSEOVER_UNIT` is `__stdcall(guidLo, guidHi, arg3, arg4)` —
// see Offsets.h. We don't use the args; they pass straight through.
using SetMouseover_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4);
using FireEventNoArgs_t = void(__fastcall *)(int eventID);
// `FUN_OBJECT_RESOLVE_BY_GUID(typeMask, debugName, guidLo, guidHi, prio)`
// — keeps the object only if its type flags intersect `typeMask`, so the
// unit|player mask returns non-null only for a live unit / player / pet /
// MC'd creature (never a gameobject or item). Same primitive
// `Unit::Flags::ResolveUnitOrPlayerByGuid` uses.
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
// True iff the current mouseover GUID resolves to a unit (not a
// gameobject / item / nothing).
bool MouseoverIsUnit() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_LO));
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_HI));
if ((lo | hi) == 0)
return false;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return fn(kUnitOrPlayerMask, "UPDATE_MOUSEOVER_UNIT", lo, hi, 0) != nullptr;
}
SetMouseover_t SetMouseover_o = nullptr;
void __stdcall SetMouseover_h(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4) {
const bool hadUnit = MouseoverIsUnit();
SetMouseover_o(guidLo, guidHi, arg3, arg4);
const bool hasUnit = MouseoverIsUnit();
// Only the unit-loss transition is missing from the engine. Any
// ->unit transition (gain / unit->unit change) already fired inline
// in the original, so `!hasUnit` prevents a double-fire there.
if (hadUnit && !hasUnit) {
auto fire = reinterpret_cast<FireEventNoArgs_t>(
static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS));
fire(Offsets::EVENT_UPDATE_MOUSEOVER_UNIT);
}
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_SET_MOUSEOVER_UNIT,
reinterpret_cast<void *>(&SetMouseover_h),
reinterpret_cast<void **>(&SetMouseover_o)};
} // namespace
} // namespace Unit::Mouseover
+87
View File
@@ -0,0 +1,87 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// UnitPosition backport.
//
// `UnitPosition("unit") → posY, posX, posZ, instanceID` — the modern
// (WoD+) world-position accessor, ported to 1.12. The world position of
// any visible unit already lives on its CGObject; we read it through the
// shared `Unit::Position` helper (the object's GetPosition virtual, vtable
// slot 5 — the same path UnitDistanceSquared / UnitInRange use).
//
// Two semantic notes:
//
// - **Order matches retail.** The engine's C3Vector is {x, y, z} with the
// standard WoW convention (x = north, y = west, z = up). Retail's
// UnitPosition returns (posY, posX, posZ) — west, north, up — so we push
// the engine's y, then x, then z. An addon ported from retail that reads
// `posY, posX = UnitPosition(u)` gets coordinates labelled exactly as it
// expects.
// - **No group restriction.** Retail returns nil for units outside your
// party/raid (a taint/privacy guard that doesn't exist in 1.12). We can
// read any *visible* unit, so this is strictly more permissive — closer
// to SuperWoW's unit-position access than to retail's restricted form.
// Units the client can't see (out of range, different zone) have no
// known position → nil.
//
// `instanceID` is the currently-loaded Map.dbc id (VAR_CURRENT_MAP_ID) —
// every visible unit shares the player's instance, so the player's map id
// is the unit's instance id.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Unit::PositionApi {
namespace {
// `UnitPosition("unit") → posY, posX, posZ, instanceID`. nil when the unit
// has no known position (recognized-but-absent token, or a visible-object
// miss). Genuinely unrecognized token strings raise a Lua error, same as
// UnitHealth("garbage") — the engine's resolver owns that contract.
int __fastcall Script_UnitPosition(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPosition(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(pos[1])); // posY (world Y, west)
Game::Lua::PushNumber(L, static_cast<double>(pos[0])); // posX (world X, north)
Game::Lua::PushNumber(L, static_cast<double>(pos[2])); // posZ (up)
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
Game::Lua::PushNumber(L, static_cast<double>(mapId));
return 4;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitPosition", &Script_UnitPosition);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "UnitPosition",
&Script_UnitPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::PositionApi
+43
View File
@@ -31,6 +31,22 @@ namespace {
using CancelAuraSend_t = void(__fastcall *)(int spellID);
// `FUN_UNIT_IS_OUTDOORS(unit) -> uint`, nonzero = outdoors (see Offsets.h).
// The thunk tail-jumps into the WMO query, so eax carries the result even
// though a decompiler renders the thunk itself as `void`.
using IsOutdoorsQuery_t = uint32_t(__fastcall *)(const void *unit);
// Tri-state indoor/outdoor read for the local player: 1 = outdoors,
// 0 = indoors, -1 = unknown (no resolvable player object, e.g. pre-world).
int PlayerOutdoorState() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return -1;
auto fn = reinterpret_cast<IsOutdoorsQuery_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_IS_OUTDOORS));
return fn(player) != 0 ? 1 : 0;
}
uint32_t PlayerMovementFlags() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
@@ -200,10 +216,37 @@ int __fastcall Script_IsAssistingRitual(void *L) {
return 1;
}
// `IsIndoors()` — 1 iff the local player is under a WMO roof (inside a
// building / cave / instance interior), else nil. Recomputed live from the
// engine's WMO geometry query, so it flips as you cross an interior
// threshold — not tied to the zone/area. nil before the player object
// exists (pre-world). Vanilla 1.12 ships no binding for this.
//
int __fastcall Script_IsIndoors(void *L) {
if (PlayerOutdoorState() == 0)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// `IsOutdoors()` — 1 iff the local player is outdoors (open sky, or not
// inside a WMO interior), else nil. Exact complement of `IsIndoors` for a
// resolvable player; both return nil pre-world. See `Script_IsIndoors`.
int __fastcall Script_IsOutdoors(void *L) {
if (PlayerOutdoorState() == 1)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMounted", &Script_IsMounted);
Game::Lua::RegisterGlobalFunction("IsIndoors", &Script_IsIndoors);
Game::Lua::RegisterGlobalFunction("IsOutdoors", &Script_IsOutdoors);
Game::Lua::RegisterGlobalFunction("Dismount", &Script_Dismount);
Game::Lua::RegisterGlobalFunction("IsStealthed", &Script_IsStealthed);
Game::Lua::RegisterGlobalFunction("IsFalling", &Script_IsFalling);