Compare commits

..
31 Commits
Author SHA1 Message Date
Brues 84da9c23df Consolidate the duplicated Spell.dbc attribute flag bits
The remaining flag-bit duplicates -- the same drift risk that caused the
AttributesEx2/Ex3 mix-up -- now live once in Offsets.h:
- SPELL_ATTR_PASSIVE (0x40): was already in Offsets.h yet copied in
  Info.cpp and Level.cpp; drop both locals.
- SPELL_ATTR_EX_CHANNELED (0x4|0x40): identical local in Cast.cpp and
  CastEvents.cpp; add to Offsets.h and repoint.
- SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20): the same bit under two names
  (Cast's _AUTOREPEAT, AutoAttack's _FLAG); unify in Offsets.h.

Single-use flag bits (TRADESPELL, RANGED, HEALTH_FUNNEL,
HIDDEN_CLIENTSIDE, ON_NEXT_SWING, NOT_RESET_AUTO_ACTIONS) and per-module
effect/target enum codes stay local -- they are not duplicated and read
clearer at the use site.
2026-08-07 17:52:55 -05:00
Brues 6e58cb81d1 Consolidate SpellIcon path and remaining Spell.dbc offset stragglers
Fold the last duplicated DBC offsets into Offsets.h:
- SpellIcon.dbc path (+0x04): 4 copies across 3 files -> OFF_SPELLICON_PATH.
  aura/Data and spell/Cast had local consts; spell/Info used two raw
  `iconRec + 4` literals.
- totem/Tracker's OFF_MISC_VALUE (0x1A8) duplicated the existing
  OFF_SPELL_RECORD_EFFECT_MISC_VALUE -- repointed. OFF_TOTEM_TOOL (0xA0) is
  a Spell.dbc field, added as OFF_SPELL_RECORD_TOTEM.

Audit result: unit-descriptor offsets were already fully centralized in
Offsets.h, and item ItemStats_C fields are read via a named struct rather
than offset consts, so neither needed work. Spell.dbc and its sub-DBC
record offsets are now a single source of truth.
2026-08-07 17:49:05 -05:00
Brues 8a065481b4 Consolidate all Spell.dbc record offsets into Offsets.h
Extend the single-source-of-truth cleanup to the whole Spell.dbc record
layout. 40 local constexpr offset copies across 16 files (spell/, totem/,
lossofcontrol/, macro/, aura/) redefined field offsets that belong in
Offsets.h: name (0x1E0), icon (0x1D4), effect (0xF4), rank (0x204),
effect implicit targets (0x148/0x154), powerType (0x7C), manaCost (0x80),
rangeIndex (0x90), castingTimeIndex (0x48), maxLevel (0x6C), spellLevel
(0x74), manaCostPercent (0x270).

Add the ones Offsets.h lacked as OFF_SPELL_RECORD_* and repoint every
local use to the Offsets:: constant, deleting the duplicates. Purely
mechanical and behavior-preserving -- every local value already matched
the canonical (verified before repointing). SpellIcon.dbc / SpellRange.dbc
sub-record offsets (0x04/0x08) stay local; they are different structs.
2026-08-07 17:44:01 -05:00
Brues 2a7cbf75ca Remove the invalid auto-learn hide gate in GetCurrentLevelSpells
SpellHiddenFromAutoLearn tested AttributesEx3 bit 0x02000000, a gate
ported from a MoP function (FUN_00911c60) whose +0x24 field is not
1.12's AttributesEx3. Verified against Spell.dbc: in 1.12 the bit is set
on only 44 of 27925 records -- a grab-bag of passive procs, odd DoT
ranks, the Night Elf priest Starshards line, and Turtle customs -- so it
is not an auto-learn flag and could only wrongly hide legitimate
learnable spells (Starshards carries it).

Drop the AttributesEx3 test and keep the verified HIDDEN_CLIENTSIDE
(Attributes bit 0x80) filter, renamed SpellHiddenFromSpellbook. The
authoritative auto-learn signal is SkillLineAbility.learnOnGetSkill
(col 9), not a Spell.dbc bit; this feature reports the broader
"trainable at level" set and applies no learnOnGetSkill filter.
2026-08-07 16:58:14 -05:00
Brues ff5ce5cdcb Fix IsRangedAutoAttackSpell to test the autorepeat flag
The check tested Attributes bit 1 (RANGED), which every ranged ability
sets -- so Aimed Shot and Multi-Shot wrongly counted as ranged
auto-attacks. The two auto-repeat spells (Auto Shot, Shoot) are the ones
with SPELL_ATTR_EX2_AUTOREPEAT_FLAG (AttributesEx2 bit 5, 0x20). Verified
from Spell.dbc: Auto Shot (75) and Shoot (5019) carry Ex2 0x20; Aimed
Shot (19434) and Multi-Shot (2643) set only RANGED, not the flag.
2026-08-07 16:52:25 -05:00
Brues 47b6a66b96 Fix GetSpellInfo isFunnel to read the health-funnel flag
isFunnel read AttributesEx bit 6, which is CHANNELED_2 -- false for
Health Funnel itself and true for unrelated channels, so it never
actually detected funnel spells. The real flag is
SPELL_ATTR_EX2_HEALTH_FUNNEL (AttributesEx2 bit 11, 0x800). Verified
from Spell.dbc: Health Funnel (755, Ex2=0x808) and Hellfire (1949,
Ex2=0x800) carry it; the channeled non-funnels Drain Life, Mind Flay,
Arcane Missiles, and Rain of Fire do not.
2026-08-07 16:51:38 -05:00
Brues e78c36bb70 Fix the spell-mod gate to read AttributesEx3, not AttributesEx2
The prior consolidation moved the spell-mod disable read from record
+0x24 to +0x20 on the handoff's claim that all three "0x24 readers"
intend AttributesEx2. A tortoise-wow sweep plus client disassembly
disproved that for the mod gate: the client's spell-mod core
(FUN_006e6b30) reads [record + 0x24] & 0x20000000 -- AttributesEx3 bit
29, SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS, which is the real "ignore
caster modifiers" flag. AttributesEx2 bit 29 is CANT_CRIT and has
nothing to do with spell mods. So +0x20 was a regression.

Add OFF_SPELL_RECORD_ATTRIBUTES_EX3 (0x24) and the correctly named
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS to Offsets.h, and point Mod.cpp
at them. Cast.cpp's autorepeat read stays at AttributesEx2 (bit 5,
AUTOREPEAT_FLAG) -- that one was a genuine EX2 flag and the move to
+0x20 was correct.

Level.cpp's auto-learn gate was ported from a Cata function
(FUN_00911c60, outside 1.12's .text) and its 1.12 meaning is
unverified, so restore it to the original +0x24 read (no behavior
change from before the consolidation) and mark the constant unverified
pending a real 1.12-client check.
2026-08-07 16:43:35 -05:00
Brues 41fb17502d Add C_Spell spell-record field readers
Add C_Spell.GetSpellDispelType, IsNextMeleeSpell, and ResetsMeleeSwing.
Each reads a Spell.dbc field straight from the record: dispel type
(0x10), the on-next-swing attribute bits (0x18 & 0x404), and the melee
swing-reset rule (InterruptFlags 0x54 has AUTOATTACK and AttributesEx2
0x20 lacks NOT_RESET_AUTO_ACTIONS). All offsets come from the shared
Offsets.h constants. This lets pfUI drop nampower's GetSpellRecField.
2026-08-07 16:35:04 -05:00
Brues a989499cae Consolidate Spell.dbc attribute offsets into Offsets.h
The Attributes (0x18), AttributesEx (0x1C), and AttributesEx2 (0x20)
record offsets were each copied as local constants across several spell
modules. AttributesEx2 was 0x24 in three of those copies, which is
actually AttributesEx3 -- an off-by-one-field bug. Spell.dbc stores each
record as a flat uint32 array by column, so AttributesEx2 (column 8) is
at 0x20. Verified against the 1.12.1 client, the server column order,
and nampower.

Move the record offsets to Offsets.h as the single source of truth (add
AttributesEx at 0x1C and InterruptFlags at 0x54; correct AttributesEx2
to 0x20) and remove the local copies in AutoAttack, Cast, CastEvents,
Info, and Level. This corrects the autorepeat aim-time skip, the
auto-learn hide check, and the spell-mod family gate -- all of which read
AttributesEx2 -- and removes the duplication that let one wrong offset
become three bugs.
2026-08-07 16:34:46 -05:00
Brues 722c257acb fix(aura): size source cache to the raid working set; never evict a present aura
The Aura::Source cache holds one entry per (target, spellId) for every
aura-applying SMSG_SPELL_GO observed — every buff on every unit in view, not
just the player's. At 256 entries the table was permanently full in a group or
raid, so nearly every new cast fell through Store's overflow branch, which
overwrote a blind round-robin slot with no presence or expiry check. That slot
was almost always a live buff, dropping its cached caster — the "buff kept its
sourceGUID for a few minutes then lost it" report (e.g. Blessing of Wisdom).

Size kCacheSize to the realistic worst case (2048 — a fully raid-buffed 40-man
plus debuffs, ~80 KB static) so the table stops living at capacity, and replace
the round-robin overflow with a presence-aware victim: prefer the
least-recently-updated ORPHAN (no live descriptor backing — out of range /
despawned), and only touch a still-present entry under true saturation (every
one of 2048 slots a live aura, practically unreachable). This extends the
descriptor-presence invariant the tick sweep and slot-reuse already honor to
the last unguarded eviction path, so an aura still on a resolvable unit keeps
its source for its whole life. Verified in-game: 30+ minutes in a full
Stormwind with no source loss.
2026-08-07 16:16:57 -05:00
Brues a6c7098106 fix(aura): match self-buffs in the PLAYER aura filter
The self-buff sourceGUID inference from 7caee62 ran only at display, so a
self-buff we never saw cast (e.g. login-persisted Inner Fire) showed
sourceUnit="player" yet was excluded from HELPFUL|PLAYER — IsPlayerCast
still keyed off the Aura::Source cache alone, which has no entry for it.

Centralize the resolution in EffectiveCaster (observed caster, else the
self-buff inference: a self-only-target aura is cast by the unit carrying
it) and route display plus both filter predicates (IsPlayerCast,
GroupIsPlayerCast) through it, so the two paths can't disagree. The
predicate stays unit-scoped: a self-buff on the player matches PLAYER; the
same self-buff on another unit resolves to that unit and correctly does not.
2026-08-07 01:51:28 -05:00
Brues 7caee620e8 fix(aura): make C_UnitAuras sourceGUID persist and infer self-buff casters
Two changes so an aura's source stops vanishing:

Pin observed casters. The WorldTick expiry sweep (and Store's slot
reuse) dropped a cache entry once its computed expirationMs elapsed —
but that time is only a base-duration estimate for non-player casters
(we lack their mod tables), so a talent-extended debuff expired in the
cache while still live on the target. The entry took the caster with it,
and the descriptor path then rendered the aura with no sourceGUID. Now
an entry whose aura the owning unit's live descriptor still lists is
never timer-reclaimed (DescriptorListsAura resolves the GUID through the
object manager); only genuinely orphaned entries — unit out of range /
stealthed / despawned — expire. The fallback path already skips expired
entries, so no phantom can resurface. PushEnriched also reports an
already-elapsed cached expiration as unknown rather than a negative
remaining time.

Infer self-buff casters. On a caster miss (no cache entry, or one with
no caster), a self-only-target aura can only have been self-cast, so the
unit carrying it is its own source. Factor the record scan out of the
C_Spell.IsSelfBuff Lua wrapper into Spell::IsSelfBuff::IsSelfBuff and
call it from PushEnriched to recover sourceUnit/sourceGUID for
pre-existing self-buffs we never saw applied. Caster is exact; timing
stays unknown.
2026-08-07 01:45:09 -05:00
Brues 954533a170 feat(aura): add CROWD_CONTROL / !CROWD_CONTROL aura filter
Reuses the C_LossOfControl classifier so the two never drift. Extract
ClassifyControlLoss out of the LoC module into a shared
Spell::CrowdControl: Classify(rec) returns the hard control-loss type
string (stun/fear/silence/root/charm/confuse/pacify/possess/disarm) and
still backs C_LossOfControl; IsCrowdControl(spellID) backs the aura
filter.

Crowd control is broader than loss of control: IsCrowdControl also
counts movement slows / snares (SPELL_AURA_MOD_DECREASE_SPEED = 33,
added to Offsets.h), which are crowd control but not a loss of control —
so a Frostbolt/Hamstring slow is CROWD_CONTROL while C_LossOfControl
still ignores it. Classify (LoC) is unchanged.

Wire it as a third CcMode dimension on the Aura::Data::Match struct,
checked in MatchesAura; tokenizer honors CROWD_CONTROL / !CROWD_CONTROL.
Docs updated. Verified in-game: Psychic Scream (fear) and a Frostbolt
slow both classify as CROWD_CONTROL; DoTs and buffs do not.
2026-08-07 01:21:17 -05:00
Brues dbe5ab40ee feat(aura): add DISPELLABLE / !DISPELLABLE aura filter
Modern DISPELLABLE means "can be dispelled/purged/stolen by SOME
mechanism, regardless of whether the player can". Confirmed against the
Turtle/Tortoise server: an aura is dispellable iff its Spell.dbc Dispel
type is Magic(1), Curse(2), Disease(3), or Poison(4) — the server's
DISPEL_ALL_MASK — and vanilla applies no per-aura "undispellable"
attribute, so the dispel type is the whole story.

Data::IsDispellable(spellID) reads the Dispel field (OFF_SPELL_DISPEL_TYPE)
via the existing SpellRecord and checks 1..4 (the type ID directly, not
dispelName != nil, in case SpellDispelType.dbc names Stealth/Invisibility).

Fold the caster and dispel predicates into one Aura::Data::Match struct
that replaces the standalone CasterMode parameter across the Find*/Push*/
Append* surface, applied by a shared MatchesAura(match, isPlayerCast,
spellID). Orthogonal filter dimensions now add a struct field instead of
another parameter. Existing caster behavior is unchanged.

Tokenizer honors DISPELLABLE / !DISPELLABLE; docs updated. Verified
in-game: HARMFUL|DISPELLABLE returns Magic debuffs, HARMFUL|!DISPELLABLE
returns physical debuffs (Rend).
2026-08-07 01:06:39 -05:00
Brues f3b328247c ci: run build steps in PowerShell, not bash
shell: bash on the self-hosted Windows runner resolved to the WSL
launcher at C:\Windows\System32\bash.exe (Git Bash isn't on the
NETWORK SERVICE account's PATH), so Resolve version died with 'no
installed distributions'. Default the build job's run steps to Windows
PowerShell 5.1 (always present) and rewrite Resolve version accordingly,
dropping the bash/WSL dependency from the native-Windows build.
2026-08-07 00:44:42 -05:00
Brues 470361fbce ci: fall back to windows-latest when self-hosted runner is offline
Probe repo runners in a pick-runner job and hand the resolved label
(self-hosted if one is online, else windows-latest) down to build via
needs, so an offline self-hosted runner no longer queues the build
indefinitely.
2026-08-07 00:30:57 -05:00
Brues 2d266a9b76 feat(aura): tokenize C_UnitAuras filters; add PLAYER/!PLAYER negation
The aura filter string was matched with raw strstr, which the modern
AuraFilters token set breaks: strstr(f, "PLAYER") also matches
RAID_PLAYER_DISPELLABLE (false PLAYER-only), and "!PLAYER" contains
"PLAYER" so it was read as PLAYER-only — the opposite of intent.

Replace it with a real tokenizer (Aura::Api::ParseFilters): tokens
separated by "|" or whitespace, each optionally negated with a leading
"!", whole-token matching. Honored tokens: HELPFUL / HARMFUL
(descriptor slot ranges) and PLAYER / !PLAYER (caster == / != the local
player). Every other AuraFilters token (RAID, CANCELABLE, DISPELLABLE,
INCLUDE_NAME_PLATE_ONLY, MAW, …) is accepted and ignored — vanilla has
no data for them.

!PLAYER makes the caster restriction ternary, so the Aura::Data layer's
`bool playerOnly` becomes `CasterMode { Any, PlayerOnly, NotPlayer }`
threaded through every Find*/Push*/Append* function (plus the
lossofcontrol call site). Existing Any/PlayerOnly behavior is preserved
exactly; NotPlayer is additive. A cache miss counts as not-player, so
!PLAYER includes auras whose cast we did not observe (matching
IsPlayerCast's miss semantics).

Also fixes: "PLAYER" alone on the by-id / by-name getters now searches
both ranges instead of wrongly restricting to helpful.

Docs: API.md gains an STE "Filter tokens" note under GetAuraDataByIndex.
2026-08-06 23:50:15 -05:00
Brues 8e3e207f83 fix(frame): show mouseover tooltip for offline party/raid members
pfUI-style unit frames have no OnEnter; their tooltip is produced by
Frame::Attributes driving the engine mouseover setter (FUN_00492890).
That setter builds the unit tooltip only when the GUID resolves to a
live object, so an offline / far out-of-range party or raid member
(roster GUID, no object) got no tooltip.

The unit-tooltip builder FUN_00529FE0 itself works offline — with no
object it fills name/level/class + the "Offline" line from the party/
raid roster (same path GameTooltip:SetUnit("party1") uses). So when the
hovered unit-frame's GUID has no live object, build the tooltip
ourselves from the roster.

Fire OnTooltipSetDefaultAnchor before building (via the self-contained
FUN_FRAME_INVOKE_SCRIPT), exactly as the engine setter does — that
FrameXML handler sets the tooltip owner/anchor and is what shows it.
Without it the first hover worked by luck but a re-hover after the
leave-clear had no owner and stayed invisible (only a live-unit hover,
which takes the engine's own anchor path, would recover it).

New offsets: VAR_GAMETOOLTIP_OBJECT_PTR (0x00B4B3C4, the mouseover
tooltip object) and OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER (0x444).
2026-08-06 23:26:52 -05:00
Brues 1fd1295679 debugtools: use DEBUG_FRAMESTACK from locale if available 2026-08-06 19:27:14 -05:00
Brues e4a75e68ca feat(frame): add GetMouseFoci — mouse-focus stack under the cursor
Modern WoW exposes GetMouseFoci() (the stack of frames under the
cursor) alongside GetMouseFocus(), with GetMouseFocus() ==
GetMouseFoci()[1]. Vanilla stores only the single topmost focus
(Script_GetMouseFocus reads *(ctx + 0x7C)); there is no stored stack.

Reimplement natively (no Lua polyfill, no hooks): walk the engine frame
list that Script_EnumerateFrames iterates (head *(ctx + 0xCC4), next
+0x308, low-bit/null sentinel), keep frames that are visible +
mouse-enabled and whose rect contains the cursor (Region getters +
cursor/effective-scale, mirroring Frame::Modern's IsMouseOver), order
top-first by strata rank then frame level (level read directly from
+0xC4), and force index 1 to the engine's stored focus so the invariant
holds by Lua table identity. Frames are pushed via the shared
UI::FrameObject::Push so == compares the same wrapper GetMouseFocus
returns.

Filters to the mouse-enabled (interactive) stack, matching 1.15.x
(verified: GetMouseFocus skips a higher non-mouse-enabled cooldown to
land on the button, and 1.15.x GetMouseFoci returns the same set).
/framestack lists more because it is a pure-geometry debug view, not the
mouse-focus set.

Docs: API.md entry (Simplified Technical English).
2026-08-06 18:42:35 -05:00
Brues 28bf41c647 locale: Backport debuff symbols and debug label
Add DEBUFF_SYMBOL_{CURSE,DISEASE,MAGIC,POISON} and DEBUG_FRAMESTACK translations to locale files (deDE, enUS, esES, frFR, ruRU, zhCN, zhTW).
2026-08-06 17:59:16 -05:00
Brues ac97f9b960 ci: add workflow_dispatch manual trigger to Build
Push-triggered runs can be silently dropped when GitHub throttles
webhooks during an Actions incident, leaving no way to kick a build.
Add a workflow_dispatch trigger as a manual escape hatch, runnable
from the Actions UI.

A single Resolve step computes is_release/tag from the pushed ref, the
dispatched ref, or an optional `tag` input, and every downstream step
keys off it — so push behavior is unchanged. Dispatch modes: run from a
vX.Y.Z tag (release build of that commit), run from a branch with a
`tag` input (release build attached to that tag), or run from a branch
with a blank input (dev build artifact). The Release step now passes
tag_name explicitly so it also works when the ref is a branch.
2026-08-06 17:17:46 -05:00
Brues 7f6c6e2dd4 consolidate UPDATE_MOUSEOVER_UNIT compat logic 2026-08-06 16:45:19 -05:00
Brues 9d09266fc6 build(embed): flag-driven embedded toc version + marker-gated dev override
Two related fixes to the embedded-addon precedence so users stop
running a stale/mismatched on-disk !!!ClassicAPI without realizing it,
while keeping the developer's disk-wins workflow intact.

Version is now a pure build-flag artifact. The embedded copy's
`## Version:` is stamped from CLASSICAPI_TAG at embed time
(cmake/embed_addon.cmake rewrites the line in-memory before hex
encoding, portable via a temp-file round-trip). The committed source
toc stays `## Version: DEV` and is never edited, so a release build is
`-DCLASSICAPI_TAG=vX.Y.Z` and nothing else. Dropped the CI sed step
that previously mutated the source toc. Moved the version-derivation
block above the embed step so CLASSICAPI_TOC_VERSION is available to it.

Dev override moved from the toc version to an explicit marker. A
`.classicapi-dev` file in the on-disk addon folder now forces the disk
copy to win regardless of version (checked via the original
FUN_FILE_READ, so it reflects a real on-disk file). Consequently `DEV`
no longer sorts as "newer than any release" in CompareVersions — it
sorts oldest — so a stray `DEV` source/clone copy on a user's disk no
longer silently shadows a newer embedded release. The normal local
loop still works without the marker (local DEV DLL + DEV disk = tie =
disk wins); the marker is only needed to prefer disk against a released
DLL. The marker is gitignored and excluded from the embed (both globs),
and the release ships only the DLL, so it can exist only in a
developer working tree.
2026-08-06 16:37:36 -05:00
Brues d972433ab0 compat(puppeteer): guard EnemyTracker against mouseover-loss fire
ClassicAPI 1.8.4 added a UPDATE_MOUSEOVER_UNIT loss-fire polyfill
(matching retail): the event now also fires when the mouseover goes
unit -> nothing, with `mouseover` already cleared. Puppeteer's
EnemyTracker OnEvent reads `unit = UnitGUID("mouseover")` on that
event, which is now nil on the loss fire; it then calls
`UnitCanAttack("player", unit)`, and Puppeteer routes UnitCanAttack
through its DoubleUnitProxy (no nil guard) straight to native
UnitCanAttack -> engine raises `Usage: UnitCanAttack("unit",
"otherUnit")` on the nil arg.

Wrap PTEnemyUpdater's OnEvent from our AddOnCompat shim and skip the
handler when UPDATE_MOUSEOVER_UNIT fires with no live mouseover, so
the nil never reaches the proxy. Gains still pass through, so enemy
tracking is unaffected. Deferred via ContinueOnAddOnLoaded (Puppeteer
loads after us) and guarded on PTEnemyUpdater existing (it is only
created when SuperWoW is present).
2026-08-06 14:23:29 -05:00
Brues b5513c6b22 fix(cbor): emit canonical map key order (RFC 7049 §3.9)
The CBOR encoder walked string-keyed tables with lua_next (pairs()
order), which Lua does not guarantee to be stable. The pfUI export
path rebuilds its config tables on every press, so each rebuild
re-hashed the same keys into a different internal layout and the map
serialized its key/value pairs in a different byte order -> different
compressed bytes -> different Base64. Harmless (every variant decodes
to the same config) but no stable fingerprint.

EncodeTable's map branch now stashes each key, records its encoded
bytes, sorts by encoded-key bytes (shorter first, then bytewise per
RFC 7049 3.9), then re-fetches key/value in sorted order. Nested
maps sort recursively, so the whole byte stream is canonical. Decode
is order-agnostic, so the round trip stays lossless.

Verified in-game: encode/decode/re-encode of a pfUI profile now
produces a byte-identical Base64 string.
2026-08-06 14:02:19 -05:00
Brues 8a434fea0e fix(aura): keep SMSG_SPELL_GO caster through the descriptor-sync race
C_UnitAuras dropped sourceGUID/sourceUnit for buffs cast by other units
(e.g. a druid's Mark of the Wild / Gift of the Wild on the player).

SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
the target's descriptor. In that window an addon aura poll runs
ReconcileCache, which calls EvictAbsent. The just-captured cache entry
named an aura the descriptor did not list yet, so EvictAbsent dropped it
and lost the caster. OnAuraAdded then recreated the entry with no caster,
so sourceGUID stayed nil. Verified by trace: every "Store NEW caster=0x8"
was followed at once by "EvictAbsent (had caster=0x8)".

Give each cache entry a write timestamp (stampMs) and exempt entries
younger than kEvictGraceMs (2s) from EvictAbsent. A real removal is far
older and still gets dropped, and OnAuraRemoved evicts genuine removals
directly, so stale-aura cleanup is unaffected.

Verified in-game: a remote druid buff now reports sourceGUID; a self-cast
buff still reports sourceGUID + sourceUnit="player".
2026-08-06 01:26:39 -05:00
Brues dbbba88aad feat(tooltip): show spell rank in the corner of SetSpellByID
`GameTooltip:SetSpellByID` now shows the spell rank in the top-right
corner of the title line. The cross-class `SetTalentByID` fallback shows
it too. Both go through `Spell::Tooltip::ShowByID`.

The engine builder `BuildSpellTooltip` (`FUN_0052E610`) sets the title
line right-text to `Rank[locale]` when `param_5` or `param_2` is not
zero. `param_5` has no other effect in the builder (confirmed by
disassembly). `SetAction` and `SetPetAction` pass `param_5=1` for this.
Native `SetSpell` and the old `SetSpellByID` passed `0`, so they did not
show the rank. `ShowByID` now passes `param_5=1`.

The change is additive. A spell with no rank (passive or racial) shows
nothing there, the same as the engine.

With this change, the macro tooltips of SuperCleveRoidMacros match the
native action-button tooltips.
2026-08-06 00:32:16 -05:00
Brues b8bd0bce4e fix(unit): gate +0xE68 sub-struct reads on OBJECT_TYPE_PLAYER, not PLAYER_CONTROLLED
The CGPlayer sub-struct at [unit+0xE68] (guild key, PLAYER_FLAGS, quest
list, visible items) exists only on real player objects. Reads of it were
gated on UNIT_FLAG_PLAYER_CONTROLLED, but the server sets that flag on
pets, totems, guardians, companions, and MC'd creatures too (Turtle
UnitDefines.h; Pet.cpp/SpellEffects.cpp/SpellAuras.cpp setters) -- all
TYPEID_UNIT creatures with no such sub-struct. On one of them the +0xE68
pointer is garbage-but-non-null, so the null check passes and the deref
AVs (ERROR #132, pfUI issue #34: UnitIsInMyGuild on a nameplate pet/totem,
frequent in crowded dungeons/raids).

Add Unit::Flags::IsPlayerObject (m_objectType == OBJECT_TYPE_PLAYER via
[obj+0x14]) and use it as the gate at every arbitrary-unit +0xE68 reader:

  - UnitIsInMyGuild  (ReadGuildKey)      -- the reported crash
  - UnitIsAFK/UnitIsDND (TestPlayerFlag) -- same latent crash on a pet
  - IsUnitOnQuest    (Quest::Log::UnitHasQuest)
  - GetInventoryItemID (non-local unit visible-items path)

Local-player-only readers are unaffected. IsPlayerControlled stays for
minion/ownership classification (Unit::Pet), which wants the broad flag.
2026-08-05 20:53:30 -05:00
Brues c3a80053e9 equipment: Backported missing localization strings 2026-08-05 15:52:02 -05:00
Brues 5a3badb5bd feat(macro): add C_Macro.CreateMacro / EditMacro with string icons
The vanilla CreateMacro / EditMacro globals take an icon INDEX into the
GetMacroIconInfo list, and that list omits INV_* item icons entirely --
so item icons are unreachable through the stock API even though the
engine stores and renders them fine. C_Macro.CreateMacro / EditMacro
accept the icon as a texture string (a full Interface\Icons\<name> path
or a bare <name> basename) instead, writing the basename directly to the
macro struct; GetMacroInfo re-prefixes it as Interface\Icons\%s on read,
so any Interface\Icons\ texture round-trips.

Both go straight to the engine's own workers (FUN_004f1010 create /
FUN_004f1280 edit), which do the bounded copy, re-run the primary-spell
parser, set the persist-on-logout dirty flag, and refresh the action
bars. EditMacro resolves its index arg as a slot number or a macro name
(via the GetMacroIndexByName resolver), leaves any nil field unchanged,
and reads back the entry's local flag so the worker's unconditional
rewrite of it is a no-op.

The legacy globals stay untouched (index-only); string-icon callers opt
in through the C_Macro namespace. Verified in-game incl. an INV_* item
icon surviving a relog. Unblocks a pfUI macro-UI remodel.
2026-08-05 01:53:08 -05:00
52 changed files with 2044 additions and 505 deletions
+77 -15
View File
@@ -4,48 +4,110 @@ on:
push:
branches: ['**']
tags: ['v*']
# Manual escape hatch (e.g. when push webhooks are throttled during a
# GitHub Actions incident). Run from the Actions UI:
# - select a `vX.Y.Z` tag ref -> release build of that tag, OR
# - run from a branch and pass `tag: vX.Y.Z` -> release build that
# creates/attaches the release to that tag.
# Leave `tag` blank on a branch run for a plain dev build (artifact).
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v2.0.0). Blank = dev build.'
required: false
default: ''
permissions:
contents: write
jobs:
# Decide which runner the build lands on. `runs-on` is fixed before a job
# starts and a job pointed at an offline self-hosted runner queues forever
# instead of failing over — so we probe first and hand the label down.
pick-runner:
runs-on: ubuntu-latest
outputs:
label: ${{ steps.pick.outputs.label }}
steps:
- name: Probe for an online self-hosted runner
id: pick
env:
# Listing runners needs repo-admin access; the default GITHUB_TOKEN
# does NOT have it. Create a PAT (classic: `repo`; fine-grained:
# Administration → read) and store it as the RUNNER_CHECK_TOKEN
# secret. If the secret is missing, we fall back to windows-latest.
GH_TOKEN: ${{ secrets.RUNNER_CHECK_TOKEN }}
run: |
label=windows-latest
if [ -n "$GH_TOKEN" ]; then
online=$(gh api "repos/${{ github.repository }}/actions/runners" \
--jq '[.runners[] | select(.status=="online") | select([.labels[].name] | index("self-hosted"))] | length' \
2>/dev/null || echo 0)
if [ "${online:-0}" -gt 0 ]; then
label=self-hosted
fi
fi
echo "Selected runner: $label"
echo "label=$label" >> "$GITHUB_OUTPUT"
build:
runs-on: windows-latest
needs: pick-runner
runs-on: ${{ needs.pick-runner.outputs.label }}
# Windows PowerShell 5.1 is always present; the runner's default `pwsh`
# (PowerShell Core) is not installed by default, and `shell: bash` on a
# self-hosted box resolves to the WSL launcher in System32.
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Stamp embedded TOC version
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
# Resolve the effective version once so every step below agrees.
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
# dispatched-from-tag, or a `tag` input); the embedded addon's
# `## Version:` and the compiled CLASSIC_API_VERSION are both
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
# — no source edit.
- name: Resolve version
id: ver
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
$toc = 'AddOns/!!!ClassicAPI/!!!ClassicAPI.toc'
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:INPUT_TAG) {
$tag = $env:INPUT_TAG
} elseif ($env:GITHUB_REF -like 'refs/tags/v*') {
$tag = $env:GITHUB_REF_NAME
} else {
$tag = ''
}
$isRelease = if ($tag) { 'true' } else { 'false' }
Add-Content -Path $env:GITHUB_OUTPUT -Value "tag=$tag" -Encoding utf8
Add-Content -Path $env:GITHUB_OUTPUT -Value "is_release=$isRelease" -Encoding utf8
- name: Configure (tag)
if: startsWith(github.ref, 'refs/tags/v')
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Configure (release)
if: steps.ver.outputs.is_release == 'true'
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ steps.ver.outputs.tag }}"
- name: Configure (branch)
if: "!startsWith(github.ref, 'refs/tags/v')"
- name: Configure (dev)
if: steps.ver.outputs.is_release != 'true'
run: cmake -B build -A Win32
- name: Build
run: cmake --build build --config Release
- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/v')"
if: steps.ver.outputs.is_release != 'true'
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
if: steps.ver.outputs.is_release == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: build/Release/ClassicAPI.dll
generate_release_notes: true
+3 -1
View File
@@ -45,4 +45,6 @@
# debug information files
*.dwo
CLAUDE.md
CLAUDE.md
/AddOns/!!!ClassicAPI/.classicapi-dev
+23
View File
@@ -58,3 +58,26 @@ if C_AddOns.DoesAddOnExist("ShaguTweaks") then
end
end)
end
-- Guard UPDATE_MOUSEOVER_UNIT handlers (issue #12).
function CAPI_MouseoverClearedCompat(frame)
if not frame then return end
local originalOnEvent = frame:GetScript("OnEvent")
frame:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") then
return
end
if originalOnEvent then
originalOnEvent()
end
end)
end
CAPI_MouseoverClearedCompat(GameTooltip)
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-28
View File
@@ -90,31 +90,3 @@ function GameTooltip_ShowCompareItem(self, shift)
end
end
end
-- Guard the stock GameTooltip UPDATE_MOUSEOVER_UNIT handler (issue #12).
--
-- ClassicAPI's DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover LOSS too (retail
-- parity), not just on gain. The stock inline handler in FrameXML/GameTooltip.xml
-- recolors the name unconditionally:
-- _G[this:GetName().."TextLeft1"]:SetTextColor(GameTooltip_UnitColor("mouseover"))
-- and GameTooltip_UnitColor returns white (1,1,1) when "mouseover" doesn't exist.
-- So the loss fire (and the transient clear that happens when clicking a unit)
-- repaints the still-visible/fading name white before it disappears -- reported
-- as NPC and PvP-flagged-player names flashing white.
--
-- We wrap the stock handler instead of replacing its body: swallow the event
-- only when there is no mouseover unit to color, otherwise delegate to the
-- original untouched. During the loss window the name simply keeps its last
-- color instead of going white; when a mouseover resolves again it recolors
-- normally. (1.12 OnEvent handlers read the `event`/`this` globals the engine
-- sets before invoking us, so calling the original as a plain function works.)
local originalOnEvent = GameTooltip:GetScript("OnEvent")
GameTooltip:SetScript("OnEvent", function()
if ( event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") ) then
return;
end
if ( originalOnEvent ) then
originalOnEvent();
end
end)
+30
View File
@@ -2,7 +2,31 @@ if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
DEBUFF_SYMBOL_CURSE = "Fl";
DEBUFF_SYMBOL_DISEASE = "Kr";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Gi";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d fehlen)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "In Beuteln platzieren";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Diesen Platz einbeziehen";
EQUIPMENT_SETS = "Ausrüstungs-Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ein Ausrüstungsset mit diesem Namen gibt es bereits.";
EQUIPMENT_SETS_TOO_MANY = "Ihr könnt keine weiteren Ausrüstungssets erstellen.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Spezialisierung zuweisen:";
EQUIPMENT_SET_EDIT = "Name/Icon ändern";
EQUIPMENT_SET_SETTINGS = "Einstellungen";
EQUIPSET_EQUIP = "Anlegen";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar ist voll.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
FOCUSTARGET = "Ziel als Fokus festlegen";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
@@ -15,6 +39,10 @@ COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Alle Symbole";
ICON_FILTER_ITEM = "Gegenstände";
ICON_FILTER_SPELL = "Zauber";
ICON_SELECTION_TITLE_CURRENT = "Aktuell ausgewählt";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
+30
View File
@@ -1,11 +1,39 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
EQUIPMENT_SET_EDIT = "Change Name/Icon";
EQUIPMENT_SET_SETTINGS = "Settings";
EQUIPSET_EQUIP = "Equip";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
ICON_FILTER_SPELL = "Spells";
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
@@ -39,6 +67,8 @@ ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "s";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,7 +2,31 @@ if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Mal";
DEBUFF_SYMBOL_DISEASE = "En";
DEBUFF_SYMBOL_MAGIC = "Mag";
DEBUFF_SYMBOL_POISON = "Ve";
DEBUG_FRAMESTACK = "Trama de pila";
EQUIPMENT_MANAGER = "Gestor de equipamiento";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(falta %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Colocar en bolsas";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Incluir esta casilla";
EQUIPMENT_SETS = "Conjuntos de equipamiento: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ya existe un conjunto de equipamiento con ese nombre.";
EQUIPMENT_SETS_TOO_MANY = "Ya no puedes crear más equipamientos.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Asignar a especialización:";
EQUIPMENT_SET_EDIT = "Cambiar nombre/icono";
EQUIPMENT_SET_SETTINGS = "Config.";
EQUIPSET_EQUIP = "Equipar";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inventario lleno.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
FOCUSTARGET = "Establecer objetivo";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
@@ -15,6 +39,10 @@ COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Todos los iconos";
ICON_FILTER_ITEM = "Objetos";
ICON_FILTER_SPELL = "Hechizos";
ICON_SELECTION_TITLE_CURRENT = "Selección actual";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
DEBUFF_SYMBOL_CURSE = "Mn";
DEBUFF_SYMBOL_DISEASE = "Md";
DEBUFF_SYMBOL_MAGIC = "Mg";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Empilement des cadres";
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(objet manquant : %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Mettre en sac";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Inclure cet emplacement";
EQUIPMENT_SETS = "Ensembles d'équipement : |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Un ensemble d'équipement portant ce nom existe déjà.";
EQUIPMENT_SETS_TOO_MANY = "Vous ne pouvez plus créer de nouveaux ensembles d'équipement.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assigner à la spécialisation :";
EQUIPMENT_SET_EDIT = "Modifier le nom/l'icône";
EQUIPMENT_SET_SETTINGS = "Réglages";
EQUIPSET_EQUIP = "Equiper";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventaire est plein.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GEARSETS_POPUP_TEXT = "Nom de l'ensemble (16 caractères max.) :";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Toutes les icônes";
ICON_FILTER_ITEM = "Objets";
ICON_FILTER_SPELL = "Sorts";
ICON_SELECTION_TITLE_CURRENT = "Sélection actuelle";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
EQUIPMENT_SET_EDIT = "Изменить название/значок";
EQUIPMENT_SET_SETTINGS = "Параметры";
EQUIPSET_EQUIP = "Надеть";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предм
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Все значки";
ICON_FILTER_ITEM = "Предметы";
ICON_FILTER_SPELL = "Заклинания";
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
EQUIPMENT_SET_EDIT = "修改名称/图标";
EQUIPMENT_SET_SETTINGS = "设置";
EQUIPSET_EQUIP = "装备";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有图标";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法术";
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
DEBUFF_SYMBOL_CURSE = "咒";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架堆疊";
EQUIPMENT_MANAGER = "裝備管理員";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d件%s|cffff0000(缺少%d件)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放進背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包含此欄位";
EQUIPMENT_SETS = "裝備設定:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "您所選擇的名稱已經有裝備設定使用了。";
EQUIPMENT_SETS_TOO_MANY = "你無法製造更多的新裝備設定。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定專精:";
EQUIPMENT_SET_EDIT = "改變名稱/圖示";
EQUIPMENT_SET_SETTINGS = "設定";
EQUIPSET_EQUIP = "裝備";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GEARSETS_POPUP_TEXT = "輸入設定名稱(最多16個字元):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有圖示";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法術";
ICON_SELECTION_TITLE_CURRENT = "目前選擇";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
+1 -1
View File
@@ -1018,7 +1018,7 @@ end
local function _UpdateFrameStackTooltip(tooltip, showHidden)
tooltip:ClearLines();
tooltip:AddLine("Frame Stack", 1, 1, 1);
tooltip:AddLine(DEBUG_FRAMESTACK or "Frame Stack", 1, 1, 1);
local matches = _CollectFramesUnderMouse(showHidden);
local count = table.getn(matches);
if (count == 0) then
+37 -27
View File
@@ -25,6 +25,39 @@ set(TINYCBOR_SOURCES
external/tinycbor/src/cborparser_float.c
external/tinycbor/src/cborerrorstrings.c)
# Version derivation. Dev builds use a sentinel "newer than every
# plausible release" so addon-side `CLASSIC_API_VERSION >= X` gates
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
# embedded addon's `## Version:` toc line (substituted in
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
# only the embedded copy is stamped, so a release build needs nothing
# but the flag — no source edit.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TOC_VERSION "DEV")
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
set(CLASSICAPI_TOC_VERSION
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
@@ -34,12 +67,16 @@ set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
# out of the embed inputs (embed_addon.cmake filters it too).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
@@ -71,33 +108,6 @@ add_library(${PROJECT_NAME} SHARED
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
# 99,999,999. Picked so addon-side feature gates of the form
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
+2 -2
View File
@@ -35,7 +35,7 @@ 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`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [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` |
@@ -49,7 +49,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `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` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
+24 -1
View File
@@ -34,6 +34,10 @@ if(NOT ADDON_NAME)
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
# Never embed the developer marker — it must reflect on-disk presence
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
# gitignored so it only exists in a developer working tree anyway.
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
@@ -41,7 +45,26 @@ set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
# Stamp the addon's `## Version:` line from the build flag rather
# than the committed source (which stays `DEV`). This is what makes
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
# before hex-encoding; every other file is embedded verbatim.
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
file(READ "${ABS_PATH}" TOC_TEXT)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
# Round-trip through a temp file to hex-encode the modified text
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
file(REMOVE "${TOC_TMP}")
else()
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
endif()
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
+95
View File
@@ -159,6 +159,7 @@ build instructions.
- [`region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])`](#regionismouseovertopoffset-bottomoffset-leftoffset-rightoffset)
- [`region:GetRect()`](#regiongetrect)
- [`region:IsDragging()`](#regionisdragging)
- [`GetMouseFoci()`](#getmousefoci)
- [`frame:SetShown(shown)`](#framesetshownshown)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
@@ -335,6 +336,7 @@ build instructions.
- [`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line)
- [`GetMacroSpell(macroSlot)`](#getmacrospellmacroslot)
- [`GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`](#getmacroicons--getmacroitemicons--getloosemacroicons--getloosemacroitemicons)
- [`C_Macro.CreateMacro` / `C_Macro.EditMacro`](#c_macrocreatemacro--c_macroeditmacro)
- [Mail](#mail)
- [`GetSendMailItemLink([attachmentIndex])`](#getsendmailitemlinkattachmentindex)
@@ -3718,6 +3720,20 @@ fontstrings) always report `false`, since only frames can be dragged.
if self:IsDragging() then ... end
```
### `GetMouseFoci()`
Returns a table of the mouse-enabled frames under the cursor, from top
to bottom. Index 1 is the frame that `GetMouseFocus()` returns, so
`GetMouseFocus() == GetMouseFoci()[1]` is always true.
```lua
local foci = GetMouseFoci()
-- foci[1] == GetMouseFocus()
for i = 1, table.getn(foci) do
print(foci[i]:GetName() or "<anonymous>")
end
```
### `frame:SetShown(shown)`
`Show()` if `shown` is truthy, `Hide()` otherwise. Registered for
@@ -8117,6 +8133,60 @@ MPQ will appear in both the loose and mpq lists).
The vanilla legacy globals `GetNumMacroIcons` / `GetMacroIconInfo`
remain available unchanged.
### `C_Macro.CreateMacro` / `C_Macro.EditMacro`
These functions create or edit a macro. You give the icon as a texture
string — a full `Interface\Icons\<name>` path or a bare `<name>`
basename — instead of a `GetMacroIconInfo` index.
```lua
C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
C_Macro.EditMacro(index, name, iconTexture, body) -> index
```
Why this exists: the vanilla `CreateMacro` / `EditMacro` globals take an
icon index into the `GetMacroIconInfo` list. That list does not include
`INV_*` item icons. As a result, the stock API cannot set an item icon,
even though the engine stores and shows it correctly. These two functions
write the icon basename directly. Any texture under `Interface\Icons\`,
including an item icon, can be saved.
`CreateMacro(name, iconTexture, body, isCharacterMacro)`:
- `name` — the macro name. This argument is necessary.
- `iconTexture` — a string, for example `"INV_Sword_25"` or
`"Interface\\Icons\\INV_Sword_25"`. The function removes the
`Interface\Icons\` prefix, and adds it again when a caller reads the
icon. A nil value sets no icon.
- `body` — the macro text. A nil value sets an empty body.
- `isCharacterMacro` — a true value puts the macro in the per-character
tab. A false or nil value uses the general tab.
- The function returns the 1-based index of the new macro. It returns nil
when the list is full (18 macros per tab).
`EditMacro(index, name, iconTexture, body)`:
- `index` — a 1-based slot number or a macro name.
- `name` / `iconTexture` / `body` — a nil argument keeps that field
unchanged.
- The function returns the 1-based index of the macro. It returns nil when
`index` does not match a macro that exists.
```lua
-- item icon that legacy CreateMacro cannot set:
local i = C_Macro.CreateMacro("Bag", "INV_Misc_Bag_08", "/say hi", false)
-- change only the icon; name and body stay:
C_Macro.EditMacro(i, nil, "INV_Potion_51", nil)
-- edit by name:
C_Macro.EditMacro("Bag", nil, nil, "/wave")
```
The `iconTexture` argument must be a string. `CreateMacro` reads a number
as no icon. `EditMacro` reads a number as no change. For the numeric-index
path, use the legacy globals.
The legacy `CreateMacro` / `EditMacro` globals do not change. They stay
index-only. String-icon callers use the `C_Macro` namespace instead.
Edits persist across sessions, the same as edits in the Macro UI.
## Mail
Backports of modern WoW's mail-attachment link getters. Both follow
@@ -13645,6 +13715,31 @@ if d then
end
```
**Filter tokens.** `filter` uses the modern AuraFilters format: tokens
separated by `|` or spaces, each token optionally negated with a
leading `!`. This build honors:
- `HELPFUL` / `HARMFUL` — buffs / debuffs. With neither token the
query returns both (the indexed getter defaults to helpful).
- `PLAYER` / `!PLAYER` — only auras the local player cast, or only
auras the player did not cast. Caster data comes from casts this
session, so an aura present before you saw it cast has no caster and
counts as not-player.
- `DISPELLABLE` / `!DISPELLABLE` — only auras that can be dispelled,
purged, or stolen (dispel type Magic, Curse, Disease, or Poison), or
only auras that cannot. This is "can it be removed at all", not "can
you remove it".
- `CROWD_CONTROL` / `!CROWD_CONTROL` — only auras that are a crowd-
control effect (stun, fear, silence, root, charm, confuse, disarm, and
movement slows), or only auras that are not. Shares the loss-of-control
classifier with `C_LossOfControl`, plus snares (which are crowd
control but not loss of control).
All other AuraFilters tokens (`RAID`, `CANCELABLE`,
`INCLUDE_NAME_PLATE_ONLY`, `MAW`, and the rest) are accepted and
ignored — vanilla has no data for them. Token matching is whole-token,
so `RAID_PLAYER_DISPELLABLE` is not read as `PLAYER`.
### `C_UnitAuras.GetBuffDataByIndex(unit, index)` / `GetDebuffDataByIndex(unit, index)`
Convenience wrappers locking the filter to `HELPFUL` or `HARMFUL`
+119 -4
View File
@@ -202,6 +202,19 @@ enum Offsets {
// GO field at +0x370.
FUN_GAMETOOLTIP_BUILD_UNIT = 0x00529FE0,
FUN_GAMETOOLTIP_BUILD_GAMEOBJECT = 0x0052AA20,
// Pointer to the GameTooltip CFrameScriptObject the engine's mouseover
// setter builds into (`DAT_00b4b3c4`; the value at this address is the
// tooltip object). `Frame::Attributes` reads it to build the unit tooltip
// for offline/out-of-range party & raid members, which FUN_00492890 skips.
VAR_GAMETOOLTIP_OBJECT_PTR = 0x00B4B3C4,
// The tooltip's OnTooltipSetDefaultAnchor handler slot (the `{handler,
// context}` pair the script resolver maps that name to). The engine
// mouseover setter fires this before building to (re)establish the
// tooltip owner/anchor via FrameXML's GameTooltip_SetDefaultAnchor —
// without it a rebuild after the tooltip was hidden has no owner and
// stays invisible. `Frame::Attributes` fires it (via the self-contained
// FUN_FRAME_INVOKE_SCRIPT) before the offline roster build.
OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER = 0x444,
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
@@ -1498,6 +1511,16 @@ enum Offsets {
// cursor is over no mouse-enabled frame. `Frame::Attributes` polls this to
// drive the `mouseover` override from the hovered frame's `unit` attribute.
OFF_UI_CONTEXT_MOUSE_FOCUS = 0x7C,
// Head of the engine's frame list (`*(*VAR_UI_CONTEXT_PTR + 0xCC4)`),
// and each frame CObject's next-in-enumeration pointer at +0x308 — the
// list `Script_EnumerateFrames` (0x00705F60) walks. A node with its low
// bit set (or null) is the end sentinel. `Frame::MouseFoci` walks it to
// build the GetMouseFoci stack.
OFF_UI_CONTEXT_FRAME_LIST_HEAD = 0xCC4,
OFF_FRAME_ENUM_NEXT = 0x308,
// Frame level, a plain int at CObject +0xC4 (the value `Script_GetFrameLevel`
// at 0x007744A0 pushes as `frame[0x31]`). Read directly for stack ordering.
OFF_FRAME_LEVEL = 0xC4,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
@@ -1508,6 +1531,14 @@ enum Offsets {
// it so a unit-attributed frame registers as the mouse-focus (bare frames
// otherwise never hover). Standard `int __fastcall(void *L)` Script_* shape.
FUN_SCRIPT_FRAME_ENABLEMOUSE = 0x00777070,
// Frame predicates used by `Frame::MouseFoci` to filter the enumeration to
// the interactive stack (visible + mouse-enabled). Standard Script_* shape
// `int __fastcall(void *L)`, self at Lua idx 1, result pushed at idx 2.
FUN_SCRIPT_FRAME_IS_VISIBLE = 0x007758D0,
FUN_SCRIPT_FRAME_IS_MOUSE_ENABLED = 0x00777130,
// `frame:GetFrameStrata()` — pushes the strata name string; ranked for
// stack ordering (`Frame::MouseFoci`).
FUN_SCRIPT_FRAME_GET_STRATA = 0x007742A0,
// Button OnClick dispatcher — `__thiscall(button, buttonCode)` at
// 0x00779540, invoking the button's OnClick slot `[button+0x4CC]`. NOTE: do
// NOT MinHook it — SuperWoW's click-casting inline-hooks the same prologue
@@ -3407,6 +3438,54 @@ enum Offsets {
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
// Macro create/edit workers — back `C_Macro.CreateMacro` /
// `C_Macro.EditMacro` (see [[src/macro/Edit.cpp]]). Both store the
// icon as a BARE basename at `entry + OFF_MACRO_ICON` (256 bytes,
// no validation); `Script_GetMacroInfo` re-prefixes it as
// `Interface\Icons\%s` on read, which is why any texture that lives
// under `Interface\Icons\` — including index-less `INV_*` item icons
// — round-trips even though vanilla's index-based UI can't pick it.
//
// Create — `__fastcall(char *name /*ECX*/, char *iconBasename /*EDX,
// may be null*/, char *body /*may be null*/, uint local, int
// perCharacter) -> uint macroID`. SStrCopys name→`+0x24` (0x40),
// icon→`+0x64` (0x100) when non-null, body via `FUN_004f1550`
// (`+0x164`), runs the primary-spell parser `FUN_004EFE00`, sets the
// dirty flag `DAT_00bdcbf8=1`, and refreshes the UI. Returns 0 on
// failure (empty name, or the 18-per-scope list is full). Pass
// `local=0` to match the legacy `CreateMacro` default.
FUN_MACRO_CREATE = 0x004F1010,
// Edit — `__fastcall(uint macroID, char *name, char *iconBasename,
// char *body, uint flag)`. Hash-finds the entry by macroID (no-op if
// absent); each of name/icon/body is written only when its pointer is
// non-null (so nil = leave that field unchanged), then reparse + dirty
// flag + UI refresh, exactly like the create worker. It ALWAYS writes
// `entry + OFF_MACRO_LOCAL_FLAG = flag`, so read the current flag and
// pass it back to leave it unchanged. Chosen over replicating the
// field writes because it also fires the action-button refresh
// (`FUN_004e5cc0`/`FUN_004e5c00`) that direct writes would miss.
FUN_MACRO_EDIT = 0x004F1280,
// macroID → 0-based slot (linear scan of VAR_MACRO_SLOT_MAP);
// `0xFFFFFFFF` on miss. `slot = FUN_MACRO_ID_TO_SLOT(macroID) + 1`
// is how the legacy `CreateMacro` wrapper turns the worker's return
// into the 1-based index it hands back to Lua.
FUN_MACRO_ID_TO_SLOT = 0x004F0EC0,
// Macro name → 0-based slot; `0xFFFFFFFF` on miss (the resolver
// behind `GetMacroIndexByName`). Feeding its miss value straight to
// `FUN_MACRO_SLOT_TO_ENTRY` yields NULL (bounds-rejected), so the
// by-name and by-index EditMacro paths share one validation.
FUN_MACRO_NAME_TO_SLOT = 0x004F0FA0,
// MacroEntry field offsets. Name/body are handled by the workers; the
// icon offset is documentation, and the local flag must be read to be
// preserved across an EditMacro that doesn't mean to change it.
OFF_MACRO_NAME = 0x24, // char[0x40] inline
OFF_MACRO_ICON = 0x64, // char[0x100] inline bare basename
OFF_MACRO_LOCAL_FLAG = 0x20, // uint32 `local` flag (echoed by GetMacroInfo)
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
@@ -5680,14 +5759,46 @@ enum Offsets {
// VAR_SPELLMOD_*). Verified from FUN_006e6b30: SpellFamilyName at
// +0x280 (gates whether the player's class mods apply), 64-bit
// SpellFamilyFlags at +0x284 (selects which familyFlagBit rows to
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
// sum). AttributesEx3 (+0x24) bit 0x20000000 (IGNORE_CASTER_MODIFIERS)
// disables spell mods — the client reads [record + 0x24] in FUN_006e6b30.
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)
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (column 6, base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
OFF_SPELL_RECORD_ATTRIBUTES_EX = 0x1C, // u32 (column 7)
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x20, // u32 (column 8)
OFF_SPELL_RECORD_ATTRIBUTES_EX3 = 0x24, // u32 (column 9)
// The client's spell-mod core (FUN_006e6b30) skips all mods when this bit
// is set: it reads [record + 0x24] & 0x20000000 — AttributesEx3 bit 29,
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
// by more than one module, so kept here rather than redefined locally.
SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40,
SPELL_ATTR_EX2_AUTOREPEAT_FLAG = 0x20,
OFF_SPELL_RECORD_INTERRUPT_FLAGS = 0x54, // u32 (column 21)
// Remaining Spell.dbc record fields — the single source of truth for the
// record layout (byte = column * 4). Modules must use these rather than
// redefining local copies.
OFF_SPELL_RECORD_CASTING_TIME_INDEX = 0x48, // u32 → SpellCastTimes.dbc
OFF_SPELL_RECORD_MAX_LEVEL = 0x6C, // u32 (scaling cap level)
OFF_SPELL_RECORD_SPELL_LEVEL = 0x74, // u32 (this rank's effective level)
OFF_SPELL_RECORD_POWER_TYPE = 0x7C, // i32 (0=mana,1=rage,2=focus,3=energy,4=happiness)
OFF_SPELL_RECORD_MANA_COST = 0x80, // u32 base cost
OFF_SPELL_RECORD_RANGE_INDEX = 0x90, // u32 → SpellRange.dbc
OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A = 0x148,// i32[3] implicit target A
OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_B = 0x154,// i32[3] implicit target B
OFF_SPELL_RECORD_MANA_COST_PERCENT = 0x270, // i32 % of base resource (0 = flat)
OFF_SPELL_RECORD_RANK = 0x204, // char*[9] localized rank text
OFF_SPELL_RECORD_TOTEM = 0xA0, // i32[2] required totem/tool item IDs
// SpellIcon.dbc record's path string field (char* at +0x04). Distinct
// struct from Spell.dbc — reached via SpellIcon.dbc[Spell.SpellIconID].
OFF_SPELLICON_PATH = 0x04,
// Spell.dbc Mechanic field — the spell-level SpellMechanic ID (→
// SpellMechanic.dbc), 0 = none. Field 5 of the record
@@ -5709,6 +5820,10 @@ enum Offsets {
SPELL_AURA_MOD_CHARM = 6,
SPELL_AURA_MOD_FEAR = 7,
SPELL_AURA_MOD_STUN = 12,
// Movement slow / snare. Not a loss-of-control (C_LossOfControl omits it),
// but the C_UnitAuras CROWD_CONTROL filter counts it — see
// Spell::CrowdControl::IsCrowdControl.
SPELL_AURA_MOD_DECREASE_SPEED = 33,
SPELL_AURA_MOD_PACIFY = 25,
SPELL_AURA_MOD_ROOT = 26,
SPELL_AURA_MOD_SILENCE = 27,
+52 -10
View File
@@ -60,6 +60,13 @@ namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
// Developer marker. When this file exists in the on-disk addon folder,
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
// `DecideSource`). It is gitignored, excluded from the embed, and the
// release ships only the DLL — so it can only exist where a developer
// deliberately created it, never in a normal user's install.
constexpr const char *kDevMarkerFile = ".classicapi-dev";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
@@ -156,17 +163,20 @@ bool ExtractTocVersion(const char *content, size_t size,
return false;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
// sentinel meaning "newer than any real release" — local dev builds
// always win against installed-on-disk versions. Otherwise the two
// strings are walked as dot-separated numeric semver components
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
// local-build sentinel: two DEV builds are equal, but a DEV build sorts
// BELOW any real release. Dev-over-release precedence is signalled
// explicitly by the `.classicapi-dev` marker now — NOT by the toc
// version — so a stray `## Version: DEV` disk copy (repo clone / old
// bundle) never silently shadows a newer embedded release. Otherwise
// the two strings are walked as dot-separated numeric semver components
// (`1.2` < `1.10`).
int CompareVersions(const char *a, const char *b) {
const bool aDev = std::strcmp(a, "DEV") == 0;
const bool bDev = std::strcmp(b, "DEV") == 0;
if (aDev && bDev) return 0;
if (aDev) return 1;
if (bDev) return -1;
if (aDev) return -1;
if (bDev) return 1;
while (*a || *b) {
int va = 0, vb = 0;
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
@@ -203,16 +213,48 @@ void EnsureEmbeddedVersionExtracted() {
}
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
// the release ships only the DLL, so a normal user's install can't
// carry it.
bool DiskHasDevMarker() {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
void *buf = nullptr;
size_t size = 0;
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
// our hook), parses its version, compares to the embedded version,
// and caches the winner. If disk has no TOC at all, embedded wins by
// default.
// The `.classicapi-dev` marker forces disk unconditionally (explicit
// developer override). Otherwise reads the on-disk TOC via the original
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
// the embedded version, and caches the newer. If disk has no TOC at
// all, embedded wins by default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
// Explicit developer override: disk wins regardless of version. This
// is how a dev keeps disk precedence when running local Lua against a
// *released* DLL (where the embedded version would otherwise be newer
// and win). Dev intent lives in this marker, decoupled from the toc
// version — so users never get silently shadowed by a stray copy.
if (DiskHasDevMarker()) {
g_source = Source::Disk;
return;
}
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
+108 -61
View File
@@ -17,12 +17,20 @@
// nameplate flags, etc.) are populated with vanilla-truthful
// defaults so consumers reading those keys get sensible values.
//
// Filter parsing here mirrors what most modern addons actually pass:
// "HELPFUL" / "HARMFUL" are honored, every other modern filter
// (`PLAYER` / `RAID` / `CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) is
// accepted but no-ops — they'd require either source-GUID tracking
// (`PLAYER`), class-dispel-matrix infra (`RAID`), or systems
// vanilla doesn't have at all.
// Filter parsing (`ParseFilters`) tokenizes the modern AuraFilters string:
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
// `!CROWD_CONTROL` (is / isn't a hard control effect, via the shared
// `Spell::CrowdControl` classifier that also backs C_LossOfControl). Every
// other modern token (`RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`,
// …) is accepted and ignored — they need a class-dispel matrix or systems
// vanilla has no data for.
#include "Data.h"
@@ -60,24 +68,72 @@ uint64_t GuidForOutOfRange(const char *token) {
return Unit::Identity::GuidForToken(token);
}
// Parses the filter string. Returns Helpful by default; Harmful if
// the string contains the substring "HARMFUL". Case-sensitive — the
// modern API documents the filter tokens as upper-case constants
// (`"HELPFUL"` etc.) and addons that pass them through case-changing
// transforms are already broken on modern too.
Data::Filter ParseFilter(const char *filter) {
if (filter == nullptr)
return Data::Filter::Helpful;
if (strstr(filter, "HARMFUL") != nullptr)
return Data::Filter::Harmful;
return Data::Filter::Helpful;
// Parsed AuraFilters string. `helpful`/`harmful` record which range tokens
// were present (neither → both, matching modern's default); `caster` carries
// the PLAYER / !PLAYER restriction.
struct ParsedFilter {
bool helpful = false;
bool harmful = false;
Data::CasterMode caster = Data::CasterMode::Any;
Data::DispelMode dispel = Data::DispelMode::Any;
Data::CcMode cc = Data::CcMode::Any;
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc}; }
};
// Reduces the parsed range tokens to the single `Data::Filter` the indexed /
// by-id / by-name getters take: Harmful when HARMFUL was given, else Helpful
// (the modern default). HELPFUL wins ties are irrelevant here — an indexed
// getter reads one range.
Data::Filter RangeFilter(const ParsedFilter &pf) {
return pf.harmful && !pf.helpful ? Data::Filter::Harmful
: Data::Filter::Helpful;
}
// The `PLAYER` filter token restricts results to auras the local player
// cast. No other modern token contains the substring "PLAYER", so a plain
// substring test is safe.
bool HasPlayerFilter(const char *filter) {
return filter != nullptr && strstr(filter, "PLAYER") != nullptr;
// Tokenizes the filter string. Case-sensitive (modern documents the tokens as
// upper-case constants). `HELPFUL`/`HARMFUL` are positive range selectors;
// negating them is meaningless for our slot-based ranges, so a leading `!` is
// ignored on those. `PLAYER` honors negation (`!PLAYER` → NotPlayer). Any
// unrecognized token is accepted and skipped.
ParsedFilter ParseFilters(const char *filter) {
ParsedFilter out;
if (filter == nullptr)
return out;
for (const char *p = filter; *p != '\0';) {
while (*p == '|' || *p == ' ' || *p == '\t')
++p;
if (*p == '\0')
break;
bool negate = false;
if (*p == '!') {
negate = true;
++p;
}
char tok[64];
size_t n = 0;
while (*p != '\0' && *p != '|' && *p != ' ' && *p != '\t') {
if (n + 1 < sizeof(tok))
tok[n++] = *p;
++p;
}
tok[n] = '\0';
if (strcmp(tok, "HELPFUL") == 0)
out.helpful = true;
else if (strcmp(tok, "HARMFUL") == 0)
out.harmful = true;
else if (strcmp(tok, "PLAYER") == 0)
out.caster = negate ? Data::CasterMode::NotPlayer
: Data::CasterMode::PlayerOnly;
else if (strcmp(tok, "DISPELLABLE") == 0)
out.dispel = negate ? Data::DispelMode::NotDispellable
: Data::DispelMode::DispellableOnly;
else if (strcmp(tok, "CROWD_CONTROL") == 0)
out.cc = negate ? Data::CcMode::NotCrowdControl
: Data::CcMode::CrowdControlOnly;
// else: accepted and ignored (retail-only / unimplemented token).
}
return out;
}
const char *ArgUnit(void *L, int idx) {
@@ -102,10 +158,10 @@ const char *ArgOptString(void *L, int idx) {
// or nil if no such aura. Used by `GetAuraDataByIndex` and the
// filter-locked aliases.
int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter, bool playerOnly = false) {
Data::Filter filter, Data::Match match = {}) {
const uint8_t *unit = ResolveUnit(unitToken);
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
const int slot = Data::FindNthSlot(unit, index, filter, match);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
@@ -113,14 +169,14 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, nearby party range fluctuation) may still be live in
// the Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
if (Data::PushNthCacheFallback(L, unit, index, filter, match))
return 1;
} else {
// No live CGUnit — an out-of-range / cross-map groupmate. The server
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
// them the same way the built-in UnitBuff/UnitDebuff do.
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, playerOnly))
filter, match))
return 1;
}
Game::Lua::PushNil(L);
@@ -135,8 +191,8 @@ int __fastcall Script_GetAuraDataByIndex(void *L) {
Game::Lua::PushNil(L);
return 1;
}
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr),
HasPlayerFilter(filterStr));
const ParsedFilter pf = ParseFilters(filterStr);
return PushAuraByIndex(L, unit, index, RangeFilter(pf), pf.ToMatch());
}
int __fastcall Script_GetBuffDataByIndex(void *L) {
@@ -168,23 +224,19 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
return 1;
}
const uint8_t *unit = ResolveUnit(unitToken);
Data::Filter f;
const Data::Filter *fp = nullptr;
if (filterStr != nullptr) {
f = ParseFilter(filterStr);
fp = &f;
}
const bool playerOnly = HasPlayerFilter(filterStr);
const ParsedFilter pf = ParseFilters(filterStr);
Data::Filter f = RangeFilter(pf);
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellID(
unit, static_cast<uint32_t>(spellID), fp, playerOnly);
unit, static_cast<uint32_t>(spellID), fp, pf.ToMatch());
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
static_cast<uint32_t>(spellID), fp,
playerOnly)) {
pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
@@ -201,21 +253,17 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
return 1;
}
const uint8_t *unit = ResolveUnit(unitToken);
Data::Filter f;
const Data::Filter *fp = nullptr;
if (filterStr != nullptr) {
f = ParseFilter(filterStr);
fp = &f;
}
const bool playerOnly = HasPlayerFilter(filterStr);
const ParsedFilter pf = ParseFilters(filterStr);
Data::Filter f = RangeFilter(pf);
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, playerOnly);
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, pf.ToMatch());
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
spellName, fp, playerOnly)) {
spellName, fp, pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
@@ -243,7 +291,7 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, bool playerOnly) {
Data::Filter filter, int &nextKey, Data::Match match) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
@@ -253,14 +301,15 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !Data::IsPlayerCast(unit, slot))
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
Data::ReadSpellID(unit, slot)))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
Game::Lua::SetTable(L, outerIdx);
}
// Append auras the descriptor dropped but Aura::Source still has live.
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
Data::AppendCacheFallbacks(L, unit, filter, match, outerIdx, nextKey);
}
int __fastcall Script_GetUnitAuras(void *L) {
@@ -274,26 +323,24 @@ int __fastcall Script_GetUnitAuras(void *L) {
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
// both ranges restricted to player-cast auras (matching retail).
const bool playerOnly = HasPlayerFilter(filterStr);
const bool hasHelpful = filterStr != nullptr && strstr(filterStr, "HELPFUL") != nullptr;
const bool hasHarmful = filterStr != nullptr && strstr(filterStr, "HARMFUL") != nullptr;
const bool both = !hasHelpful && !hasHarmful;
const ParsedFilter pf = ParseFilters(filterStr);
const bool both = !pf.helpful && !pf.harmful;
int nextKey = 1;
if (unit != nullptr) {
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
if (pf.helpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, pf.ToMatch());
if (pf.harmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, pf.ToMatch());
} else {
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
// group-member aura array (spell IDs the server still transmits), the
// same source the built-in UnitBuff/UnitDebuff read out of range.
const uint64_t guid = GuidForOutOfRange(unitToken);
if (hasHelpful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, playerOnly, 1, nextKey);
if (hasHarmful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, playerOnly, 1, nextKey);
if (pf.helpful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, pf.ToMatch(), 1, nextKey);
if (pf.harmful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, pf.ToMatch(), 1, nextKey);
}
return 1;
}
+146 -66
View File
@@ -19,6 +19,8 @@
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -28,13 +30,6 @@ namespace Aura::Data {
namespace {
// Spell.dbc record field offsets used locally. `spell/Info.cpp` has
// the same set of locals for `Script_GetSpellInfo`; promoting them
// to `Offsets.h` is a future refactor — for now we mirror its
// convention so both files stay in sync.
constexpr int OFF_SPELL_ICON_ID = 0x1D4;
constexpr int OFF_SPELLICON_PATH = 0x04;
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
@@ -72,13 +67,13 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
if (spellRecord == nullptr)
return nullptr;
const int iconID = *reinterpret_cast<const int *>(
spellRecord + OFF_SPELL_ICON_ID);
spellRecord + Offsets::OFF_SPELL_RECORD_ICON_ID);
if (iconID <= 0)
return nullptr;
return DBC::StringField(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID),
OFF_SPELLICON_PATH);
Offsets::OFF_SPELLICON_PATH);
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
@@ -209,6 +204,18 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
// Reads the player's level from UNIT_FIELD_LEVEL. Returns 0 when
// unresolved (pre-login, no descriptor) — caller should treat that
// as "skip level scaling".
// True if the engine-tick expiration `expMs` has already reached/passed now.
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
bool ExpirationElapsed(uint32_t expMs) {
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
return static_cast<int32_t>(nowMs - expMs) >= 0;
}
int PlayerLevel(const uint8_t *player) {
auto *desc = Descriptor(player);
if (desc == nullptr)
@@ -217,6 +224,25 @@ int PlayerLevel(const uint8_t *player) {
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
}
// The caster GUID attributed to `(guid, spellID)`: the observed caster from
// the Aura::Source cache, else — when we never saw the cast — the unit itself
// if the spell is a self-only-target buff (self-buffs can't be cross-cast).
// 0 when genuinely unknown. Single source of truth for both display
// (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff shows a
// source AND matches HELPFUL|PLAYER instead of the two paths disagreeing.
uint64_t EffectiveCaster(uint64_t guid, uint32_t spellID) {
if (guid == 0 || spellID == 0)
return 0;
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs) && c != 0)
return c; // observed caster
if (Spell::IsSelfBuff::IsSelfBuff(spellID))
return guid; // self-only-target aura → cast by the unit itself
return 0;
}
} // namespace
uint32_t ReadSpellID(const uint8_t *unit, int slot) {
@@ -250,16 +276,67 @@ bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
return false;
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (!Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs, &durMs))
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && EffectiveCaster(UnitGuid(unit), spellID) == player;
}
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
// per-aura "was this cast by the local player" answer (false on a cache miss).
bool CasterMatches(CasterMode caster, bool isPlayerCast) {
switch (caster) {
case CasterMode::PlayerOnly: return isPlayerCast;
case CasterMode::NotPlayer: return !isPlayerCast;
default: return true; // Any
}
}
bool IsDispellable(uint32_t spellID) {
const uint8_t *rec = SpellRecord(spellID);
if (rec == nullptr)
return false;
return casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
const uint32_t dispelType = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_DISPEL_TYPE);
// Magic(1) / Curse(2) / Disease(3) / Poison(4) — the server's
// DISPEL_ALL_MASK. None(0) / Stealth(5) / Invisibility(6) / Enrage(9)
// etc. are not dispellable by any mechanism.
return dispelType >= 1 && dispelType <= 4;
}
bool DispelMatches(DispelMode dispel, uint32_t spellID) {
switch (dispel) {
case DispelMode::DispellableOnly: return IsDispellable(spellID);
case DispelMode::NotDispellable: return !IsDispellable(spellID);
default: return true; // Any
}
}
bool CcMatches(CcMode cc, uint32_t spellID) {
switch (cc) {
case CcMode::CrowdControlOnly:
return Spell::CrowdControl::IsCrowdControl(spellID);
case CcMode::NotCrowdControl:
return !Spell::CrowdControl::IsCrowdControl(spellID);
default:
return true; // Any
}
}
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
return CasterMatches(match.caster, isPlayerCast) &&
DispelMatches(match.dispel, spellID) &&
CcMatches(match.cc, spellID);
}
// Group-array analog of IsPlayerCast: the group aura carries no caster, so we
// consult the Aura::Source cache by (guid, spellID). A miss counts as "not the
// player" (same as IsPlayerCast).
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && EffectiveCaster(guid, spellID) == player;
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly) {
Match match) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
const int start = (filter == Filter::Harmful)
@@ -272,7 +349,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
if (++matches == oneBasedIndex)
return slot;
@@ -281,7 +358,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
}
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -295,7 +372,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
continue;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
return slot;
}
@@ -303,7 +380,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
}
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -318,7 +395,7 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
return slot;
}
@@ -469,12 +546,21 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
// A cached expiration that already elapsed while the aura is still
// present (non-player casters get an underestimated base duration)
// is not meaningful — report unknown (0) rather than a negative
// remaining time.
if (expirationTime == 0.0 && expMs != 0 && !ExpirationElapsed(expMs))
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
casterGuid = c;
}
// Caster (sourceGUID/sourceUnit): observed caster, else the self-buff
// inference — same resolution the PLAYER filter uses, so a self-buff
// both shows a source and matches HELPFUL|PLAYER. Timing stays unknown
// when we never saw the cast; only the caster is inferred, and it's
// exact.
casterGuid = EffectiveCaster(guid, spellID);
}
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
@@ -548,8 +634,8 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
return false;
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
// Counts populated descriptor slots matching `filter` (and `caster`).
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
if (unit == nullptr)
return 0;
const int start = (filter == Filter::Harmful)
@@ -561,7 +647,7 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
++n;
}
@@ -571,21 +657,21 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
// `filter`/`caster`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
Filter filter, Match match) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
// the cache path must apply the same engine predicate or
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
@@ -593,7 +679,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
} // namespace
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
Filter filter, Match match) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
@@ -602,7 +688,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
const int target = oneBasedIndex - CountSlots(unit, filter, match);
if (target < 1)
return false;
const uint64_t guid = UnitGuid(unit);
@@ -613,7 +699,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
if (!FallbackEligible(unit, buf[i], filter, match))
continue;
if (++seen == target) {
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
@@ -624,7 +710,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
}
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
Match match, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
@@ -637,7 +723,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
if (!FallbackEligible(unit, buf[i], filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
@@ -668,22 +754,16 @@ uint32_t GroupSpellID(const uint16_t *arr, int slot) {
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `playerOnly` additionally requires the `Aura::Source` cache to attribute the
// `caster` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
bool playerOnly) {
Match match) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return false;
if (playerOnly) {
uint64_t c = 0;
uint32_t e = 0;
uint32_t d = 0;
if (!Aura::Source::Get(guid, id, &c, &e, &d) ||
c != Unit::Identity::PlayerGuid())
return false;
}
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
return false;
return true;
}
@@ -715,13 +795,13 @@ bool GroupArrayHasVisibleAura(const uint16_t *arr) {
// 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) {
Match match) {
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())
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
@@ -729,13 +809,13 @@ bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &
// 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) {
int oneBasedIndex, Filter filter, Match match) {
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))
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
if (++seen == oneBasedIndex) {
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
@@ -748,14 +828,14 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
// 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,
Filter filter, Match match, 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))
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
@@ -768,7 +848,7 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
// (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) {
const char *spellName, Match match) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
@@ -779,7 +859,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false);
@@ -791,7 +871,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly) {
Match match) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
@@ -802,7 +882,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -814,12 +894,12 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
playerOnly);
match);
return false;
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (guid == 0 || spellID == 0)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
@@ -835,7 +915,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
@@ -845,18 +925,18 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
playerOnly))
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
playerOnly))
match))
return true;
}
return false;
}
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (guid == 0 || spellName == nullptr || *spellName == '\0')
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
@@ -876,7 +956,7 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
@@ -886,17 +966,17 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
playerOnly))
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
playerOnly))
match))
return true;
}
return false;
}
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey) {
if (guid == 0)
return;
@@ -908,7 +988,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -917,7 +997,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, playerOnly, outerIdx,
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
nextKey);
}
+54 -9
View File
@@ -33,6 +33,37 @@ namespace Aura::Data {
// to helpful when no filter is specified.
enum class Filter { Helpful, Harmful };
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
// (`!PLAYER`). Caster attribution comes from the `Aura::Source` cache; a
// cache miss counts as "not the player" (so `PlayerOnly` excludes it and
// `NotPlayer` includes it — matching `IsPlayerCast`'s miss semantics).
enum class CasterMode { Any, PlayerOnly, NotPlayer };
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
// `Any` = no restriction; `DispellableOnly` = only auras that can be
// dispelled / purged / stolen by SOME mechanism (dispel type Magic, Curse,
// Disease, or Poison — the server's DISPEL_ALL_MASK); `NotDispellable` = the
// complement. "Regardless of whether the local player can dispel it" —
// matching the modern AuraFilters semantics.
enum class DispelMode { Any, DispellableOnly, NotDispellable };
// Restriction from the `CROWD_CONTROL` / `!CROWD_CONTROL` aura filter tokens.
// `Any` = no restriction; `CrowdControlOnly` = only auras that are a crowd-
// control effect (stun / fear / silence / root / etc., per
// `Spell::CrowdControl`); `NotCrowdControl` = the complement.
enum class CcMode { Any, CrowdControlOnly, NotCrowdControl };
// A parsed aura-filter's per-aura predicates (beyond the helpful/harmful
// range, which is the separate `Filter`). Extensible: new orthogonal filter
// dimensions add a field here rather than another function parameter.
struct Match {
CasterMode caster = CasterMode::Any;
DispelMode dispel = DispelMode::Any;
CcMode cc = CcMode::Any;
};
// Reads the spell ID at the given absolute slot (0..47). Returns 0
// if the slot is empty or the unit pointer is null.
uint32_t ReadSpellID(const uint8_t *unit, int slot);
@@ -50,27 +81,41 @@ bool IsSlotPopulated(const uint8_t *unit, int slot);
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly = false);
Match match = {});
// Finds the absolute slot of a populated aura with the given spell
// ID, optionally restricted to one filter range. `filter` of
// nullptr searches both ranges (helpful first, then harmful).
// `playerOnly` restricts to player-cast auras. Returns -1 if not found.
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// Finds the absolute slot of a populated aura whose locale-resolved
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
// or if `spellName` is null/empty.
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// True iff the aura at `slot` was cast by the local player, per the
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
// PLAYER-filtered query excludes auras whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Applies a `CasterMode` to a per-aura "was cast by the local player" answer.
// `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its negation.
bool CasterMatches(CasterMode caster, bool isPlayerCast);
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
// Magic, Curse, Disease, or Poison (`Spell.dbc` Dispel ∈ 1..4). None,
// Stealth, Invisibility, Enrage, etc. are not dispellable. Verified against
// the server's DISPEL_ALL_MASK.
bool IsDispellable(uint32_t spellID);
// Applies a full `Match` (caster + dispel predicates) to one aura, given the
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
int ReadStacks(const uint8_t *unit, int slot);
@@ -122,7 +167,7 @@ void Push(void *L, const uint8_t *unit, int slot);
// helpful/harmful classification matches `filter` and that aren't already in a
// populated descriptor slot are considered.
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly = false);
Filter filter, Match match = {});
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
@@ -130,7 +175,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
// slot so it never double-lists.
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey);
Match match, int outerIdx, int &nextKey);
// ── Out-of-range groupmate path ────────────────────────────────────────────
//
@@ -154,23 +199,23 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
// matching `filter`. On a hit pushes the table and returns true; otherwise
// pushes nothing and returns false (caller pushes nil).
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly = false);
Match match = {});
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
// restricted to one filter range (nullptr = both, helpful first). Returns true
// on a hit (table pushed), false otherwise (nothing pushed).
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// Appends every group-array aura on `guid` matching `filter` into the array
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey);
} // namespace Aura::Data
+112 -12
View File
@@ -130,13 +130,70 @@ struct Entry {
uint32_t spellId;
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
uint32_t stampMs; // last write time; EvictAbsent grace (see below)
int8_t kind; // Kind; descriptor-slot-derived, KIND_UNKNOWN if only seen via SpellGo
bool used;
};
constexpr int kCacheSize = 256;
// Sized for the realistic worst case: a fully raid-buffed 40-man plus its
// debuff load. We cache one entry per (target, spellId) for EVERY
// aura-applying SMSG_SPELL_GO we observe — not just auras on the player, but
// every buff cast on every unit in view — so the live working set in a raid
// is ~40 members × ~30 persistent buffs ≈ 1200, plus debuffs. At the old 256
// the table was permanently full and the overflow path evicted live buffs,
// dropping their source (the "lost sourceGUID on a buff" report). 2048 gives
// a fully-buffed 40-man comfortable headroom; the tick sweep still reclaims
// expired/orphaned entries so it rarely approaches full outside a raid.
// (~40 bytes/entry → ~80 KB static.)
constexpr int kCacheSize = 2048;
Entry g_cache[kCacheSize];
int g_writeCursor = 0;
// SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
// the target's descriptor, so for a brief window a just-captured entry names
// an aura the descriptor doesn't list yet. `EvictAbsent` (run from a query's
// ReconcileCache) must not drop such a fresh entry — doing so discards the
// caster we just captured, which OnAuraAdded then recreates caster-less
// (verified: druid buff → sourceGUID lost). Entries younger than this window
// are exempt from EvictAbsent; a genuinely-removed aura is far older and still
// gets dropped (and OnAuraRemoved evicts real removals directly regardless).
constexpr uint32_t kEvictGraceMs = 2000;
// True if `guid`'s live unit descriptor still lists `spellId` in any aura
// slot. The descriptor is the presence authority while the unit is in view,
// so an entry backing a still-listed aura must never be timer-reclaimed: the
// caster (sourceGUID / sourceUnit) is immutable for the aura's lifetime, but
// our `expirationMs` is only a base-duration estimate for non-player casters
// (we lack their mod tables), which underestimates talent-extended auras and
// elapses while the aura is still up. Resolves the GUID through the object
// manager — non-throwing, returns null when the unit isn't loaded / is out of
// range — so an out-of-range or stealthed unit reports false and its
// fallback-only entry stays timer-reclaimable exactly as before.
bool DescriptorListsAura(uint64_t guid, uint32_t spellId) {
if (guid == 0 || spellId == 0)
return false;
using ResolveByGuid_t = void *(__fastcall *)(int typeMask, const char *dbg,
uint32_t guidLo,
uint32_t guidHi, int priority);
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto resolve = reinterpret_cast<ResolveByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
const auto *unit = static_cast<const uint8_t *>(
resolve(kUnitOrPlayerMask, nullptr, static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0));
if (unit == nullptr)
return false;
const auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (desc == nullptr)
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (*reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4) == spellId)
return true;
}
return false;
}
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
@@ -149,9 +206,12 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
if (targetGuid == 0 || spellId == 0)
return;
const uint32_t now = NowMs();
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
e.stampMs = now; // refresh EvictAbsent grace on any touch
// Learn the classification whenever a slot-derived kind arrives —
// independent of caster/timing ownership, and never downgrade a
// known kind back to unknown.
@@ -166,19 +226,44 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
return;
}
}
// Take a free slot, else an expired one, else evict round-robin.
const uint32_t now = NowMs();
// Take a free slot, else an expired one whose aura the descriptor no
// longer lists (a still-present aura keeps its slot so its caster isn't
// lost — see DescriptorListsAura), else evict round-robin.
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
if (!e.used ||
(e.expirationMs != 0 && now >= e.expirationMs &&
!DescriptorListsAura(e.targetGuid, e.spellId))) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
kind, true};
now, kind, true};
return;
}
}
Entry &slot = g_cache[g_writeCursor];
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, kind,
true};
// Saturated. Honor the same invariant as the tick sweep: an entry whose
// aura is still present on a resolvable unit is NEVER evicted — its caster
// can't be recovered once dropped (the "buff still on the player lost its
// source" case). Prefer the least-recently-updated ORPHAN (no live
// descriptor backing: out of range / despawned / genuinely gone); only if
// every single slot is a present, live aura — true saturation we can't
// avoid — fall back to the global LRU victim. With kCacheSize sized to the
// raid working set, reaching here is already unlikely and the saturated
// fallback is practically unreachable.
Entry *orphan = nullptr;
Entry *lru = &g_cache[0];
for (auto &e : g_cache) {
if (!e.used) { // defensive: a freed slot always wins outright
orphan = &e;
break;
}
if (e.stampMs < lru->stampMs)
lru = &e;
if (DescriptorListsAura(e.targetGuid, e.spellId))
continue; // still present on a live descriptor — protect it
if (orphan == nullptr || e.stampMs < orphan->stampMs)
orphan = &e;
}
Entry *victim = (orphan != nullptr) ? orphan : lru;
*victim = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
kind, true};
}
// ---- Out-of-range group-member aura snapshots ---------------------------
@@ -534,8 +619,18 @@ void OnWorldTick() {
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
if (!e.used || e.expirationMs == 0 || now < e.expirationMs)
continue;
// A timer elapse doesn't prove the aura is gone — expirationMs is only
// a base-duration estimate for non-player casters. Keep any entry whose
// aura the owning unit's descriptor still lists (descriptor = presence
// authority in view; caster is immutable for the aura's life). Only
// genuinely orphaned entries (unit out of range / stealthed / despawned
// → no descriptor backing) are timer-reclaimed; the fallback path
// already skips expired entries, so this can't resurface a phantom.
if (DescriptorListsAura(e.targetGuid, e.spellId))
continue;
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.
@@ -775,9 +870,14 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
// Fresh SpellGo capture the descriptor hasn't synced yet — see
// kEvictGraceMs. Don't evict it; that's the sourceGUID-loss race.
if (now - e.stampMs < kEvictGraceMs)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
+74 -4
View File
@@ -17,6 +17,7 @@ extern "C" {
#include <cbor.h>
}
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
@@ -76,6 +77,27 @@ int CountTablePairs(void *L, int idx) {
return n;
}
// Encode a single Lua value at `idx` into a standalone byte vector,
// reusing the same grow-and-retry contract as Script_SerializeCBOR.
// Used to obtain a map key's encoded bytes for canonical sorting; the
// Lua stack is left untouched (EncodeValue reads but never pops).
bool EncodeValueToVector(void *L, int idx, int depth, std::vector<uint8_t> &out) {
out.assign(64, 0);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, out.data(), out.size(), 0);
if (!EncodeValue(L, idx, &enc, depth))
return false;
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
out.resize(out.size() + extra);
continue;
}
out.resize(cbor_encoder_get_buffer_size(&enc, out.data()));
return true;
}
}
bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
if (depth >= kMaxDepth) return false;
if (idx < 0) idx = Game::Lua::GetTop(L) + idx + 1;
@@ -102,15 +124,63 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
CborEncoder map;
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
// Canonical CBOR (RFC 7049 §3.9): emit map entries sorted by their
// encoded-key bytes (shorter key first, then bytewise). Lua gives no
// stable iteration order for string-keyed tables, so without this the
// same config serializes to different byte orders each call. We stash
// the keys in a holding array table (`hold`), remember each key's
// encoded bytes + slot, sort, then re-fetch key/value in sorted order.
struct Entry {
std::vector<uint8_t> key;
int slot;
};
std::vector<Entry> entries;
entries.reserve(static_cast<size_t>(pairs));
Game::Lua::NewTable(L);
const int hold = Game::Lua::GetTop(L);
int slot = 0;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
if (!EncodeValue(L, -2, &map, depth + 1) ||
!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, -3);
// key @ -2, value @ -1
std::vector<uint8_t> keyBytes;
if (!EncodeValueToVector(L, -2, depth + 1, keyBytes)) {
Game::Lua::SetTop(L, hold - 1); // drop hold + key + value
return false;
}
Game::Lua::SetTop(L, -2);
++slot;
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushValue(L, -3); // copy key to top
Game::Lua::RawSet(L, hold); // hold[slot] = key
entries.push_back(Entry{std::move(keyBytes), slot});
Game::Lua::SetTop(L, -2); // pop value, keep key for Next
}
std::sort(entries.begin(), entries.end(),
[](const Entry &a, const Entry &b) {
if (a.key.size() != b.key.size())
return a.key.size() < b.key.size();
return std::lexicographical_compare(
a.key.begin(), a.key.end(), b.key.begin(), b.key.end());
});
for (const Entry &e : entries) {
Game::Lua::PushNumber(L, static_cast<double>(e.slot));
Game::Lua::RawGet(L, hold); // push key = hold[slot]
if (!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, hold - 1);
return false;
}
Game::Lua::RawGet(L, idx); // key -> value = table[key]
if (!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, hold - 1);
return false;
}
Game::Lua::SetTop(L, -2); // pop value
}
Game::Lua::SetTop(L, hold - 1); // drop holding table
return EncOk(cbor_encoder_close_container(enc, &map));
}
+55
View File
@@ -410,6 +410,56 @@ void CallEngineMouseover(uint64_t guid) {
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0, 0);
}
// True when the GUID resolves to a live unit/player object (loaded, in range).
// False for an offline or far out-of-range party/raid member — the case the
// engine mouseover setter skips the tooltip for. Same resolver Unit::Mouseover
// uses for its unit test.
bool GuidHasLiveUnit(uint64_t guid) {
if (guid == 0)
return false;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto resolve = reinterpret_cast<void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int)>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return resolve(kUnitOrPlayerMask, "ClassicAPI",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0) != nullptr;
}
// Build the mouseover tooltip for a unit with no live object, straight from the
// party/raid roster. FUN_00529FE0 is the same unit-tooltip builder SetUnit and
// the engine mouseover use; with no object it fills name/level/class + the
// "Offline" line from the roster (verified: `GameTooltip:SetUnit("party1")`
// works for an offline member). The engine's setter (FUN_00492890) gates this
// call on a live object, so offline members get nothing — we do it here.
void BuildRosterUnitTooltip(uint64_t guid) {
void *tooltip = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_GAMETOOLTIP_OBJECT_PTR));
if (tooltip == nullptr)
return;
// Fire OnTooltipSetDefaultAnchor first, exactly as the engine mouseover
// setter (FUN_00492890) does before it builds — FrameXML's handler sets
// the tooltip's owner + anchor, which is what actually shows it. Without
// this, a rebuild after the tooltip was hidden on the previous leave has
// no owner and never re-appears (only a subsequent live-unit hover, which
// takes the engine's own anchor path, would). Use the self-contained
// invoker FUN_FRAME_INVOKE_SCRIPT, safe to call from the WorldTick.
const int anchorHandler = *reinterpret_cast<const int *>(
reinterpret_cast<uint8_t *>(tooltip) +
Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
if (anchorHandler != 0)
reinterpret_cast<void(__fastcall *)(int, void *)>(
static_cast<uintptr_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT))(anchorHandler,
tooltip);
uint32_t packed[2] = {static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32)};
reinterpret_cast<void(__thiscall *)(void *, uint32_t *)>(
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_BUILD_UNIT))(tooltip, packed);
}
void MouseoverTick() {
const void *focus = CurrentMouseFocus();
@@ -423,6 +473,11 @@ void MouseoverTick() {
if (guid != g_lastSet) {
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
// Offline / far out-of-range party or raid member: the setter wrote the
// GUID slot but skipped the tooltip (no live object). Build it from the
// roster so hovering the frame still shows name / level / "Offline".
if (guid != 0 && !GuidHasLiveUnit(guid))
BuildRosterUnitTooltip(guid);
g_lastSet = guid;
}
}
+211
View File
@@ -0,0 +1,211 @@
// 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/>.
// `GetMouseFoci()` -> table of every frame under the cursor, top-first.
//
// Modern WoW exposes GetMouseFoci() alongside GetMouseFocus(), with the
// invariant `GetMouseFocus() == GetMouseFoci()[1]`. Vanilla stores only the
// single topmost focus — Script_GetMouseFocus (0x0048DF40) reads
// `*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)` and there is no stored
// stack — so this is a native reimplementation of the frame-stack walk
// Blizzard_DebugTools does in Lua (`EnumerateFrames` + `IsMouseOver`):
//
// 1. Walk the engine frame list (the same one Script_EnumerateFrames
// 0x00705F60 iterates): head at `*(ctx + OFF_UI_CONTEXT_FRAME_LIST_HEAD)`,
// each frame's next at `+OFF_FRAME_ENUM_NEXT`, a low-bit-set / null node
// ends it.
// 2. Keep frames that are visible + mouse-enabled and whose rect contains the
// cursor (hit-test mirrors Frame::Modern's IsMouseOver: Region getters +
// cursor / effective-scale).
// 3. Order top-first by strata rank then frame level.
// 4. Force index 1 to be the engine's stored focus so the documented
// invariant holds exactly, regardless of how our sort tie-breaks.
//
// Pure query — no hooks, registered like GetMouseFocus.
#include "Game.h"
#include "Offsets.h"
#include "ui/FrameObject.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <vector>
namespace Frame::MouseFoci {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// The UI context is a pointer stored at VAR_UI_CONTEXT_PTR (null pre-world).
void *UIContext() {
return *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
}
// Strata name -> draw rank, low (behind) to high (on top). Unknown -> 0.
int StrataRank(const char *s) {
if (s == nullptr)
return 0;
static const struct {
const char *name;
int rank;
} kStrata[] = {
{"WORLD", 0}, {"BACKGROUND", 1}, {"LOW", 2},
{"MEDIUM", 3}, {"HIGH", 4}, {"DIALOG", 5},
{"FULLSCREEN", 6}, {"FULLSCREEN_DIALOG", 7}, {"TOOLTIP", 8},
};
for (const auto &e : kStrata)
if (std::strcmp(s, e.name) == 0)
return e.rank;
return 0;
}
// Region getter that pushes one number, called with `self` at Lua idx 1.
// Returns false when it pushed nil/none (unresolved rect).
bool SelfNumber(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;
}
// Boolean predicate (IsVisible / IsMouseEnabled), `self` at Lua idx 1.
bool SelfBool(void *L, uintptr_t fn) {
Game::Lua::SetTop(L, 1);
CallScript(fn, L);
return Game::Lua::ToBoolean(L, 2) != 0;
}
struct Cursor {
double x, y;
};
// GetCursorPosition and the Region rect getters share the same scale factor;
// dividing the cursor by the frame's effective scale (the field GetLeft/etc.
// themselves divide by) puts both in the frame's logical space. Mirrors
// Frame::Modern's IsMouseOver exactly.
Cursor ReadCursor(void *L) {
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
return Cursor{Game::Lua::ToNumber(L, 1), Game::Lua::ToNumber(L, 2)};
}
// With `self` at Lua idx 1, is the cursor within the frame's rect?
bool CursorOverSelf(void *L, void *obj, const Cursor &cur) {
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
if (effScale == 0.0f)
return false; // unpositioned region — no rect
double left, right, top, bottom;
if (!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom))
return false;
const double x = cur.x / effScale;
const double y = cur.y / effScale;
return x >= left && x <= right && y >= bottom && y <= top;
}
struct Hit {
void *obj;
int strata;
int level;
};
int __fastcall Script_GetMouseFoci(void *L) {
void *ctx = UIContext();
Game::Lua::SetTop(L, 0);
if (ctx == nullptr) {
Game::Lua::NewTable(L); // pre-world → empty table
return 1;
}
// The engine's authoritative topmost focus — the frame GetMouseFocus
// returns. Forced to index 1 below.
void *focus = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
const Cursor cur = ReadCursor(L);
std::vector<Hit> hits;
void *node = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
// Read the next link before any Lua work (the walk is engine-list
// based, independent of the Lua stack, but this keeps it obvious).
void *next = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_ENUM_NEXT);
Game::Lua::SetTop(L, 0);
UI::FrameObject::Push(L, node); // (self)
if (SelfBool(L, Offsets::FUN_SCRIPT_FRAME_IS_VISIBLE) &&
SelfBool(L, Offsets::FUN_SCRIPT_FRAME_IS_MOUSE_ENABLED) &&
CursorOverSelf(L, node, cur)) {
Game::Lua::SetTop(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_GET_STRATA, L); // (self, strata)
const int rank = StrataRank(Game::Lua::ToString(L, 2));
const int level = *reinterpret_cast<const int *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_LEVEL);
hits.push_back(Hit{node, rank, level});
}
node = next;
}
// Top-first: higher strata, then higher level within a strata.
std::sort(hits.begin(), hits.end(), [](const Hit &a, const Hit &b) {
if (a.strata != b.strata)
return a.strata > b.strata;
return a.level > b.level;
});
// Order the frames, forcing GetMouseFocus() to be [1] so the invariant
// holds by construction (focus may be a frame our rect test skips, e.g.
// the WorldFrame, or a same-strata/level tie our sort orders differently).
std::vector<void *> ordered;
ordered.reserve(hits.size() + 1);
if (focus != nullptr)
ordered.push_back(focus);
for (const Hit &h : hits)
if (h.obj != focus)
ordered.push_back(h.obj);
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L); // result at idx 1
for (size_t i = 0; i < ordered.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
UI::FrameObject::Push(L, ordered[i]);
Game::Lua::RawSet(L, 1); // result[i+1] = frame
}
return 1;
}
void Register() {
Game::Lua::RegisterGlobalFunction("GetMouseFoci", &Script_GetMouseFoci);
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Frame::MouseFoci
+8 -5
View File
@@ -106,14 +106,17 @@ static int __fastcall Script_GetInventoryItemID(void *L) {
int itemID = 0;
if (unitPtr == playerPtr) {
itemID = ItemIDForLocalPlayer(playerPtr, slot);
} else if (Unit::Flags::IsPlayerControlled(
} else if (Unit::Flags::IsPlayerObject(
static_cast<const uint8_t *>(unitPtr))) {
itemID = ItemIDForOtherUnit(unitPtr, slot);
} else {
// NPC / creature — no visible-items array. Returning nil here
// also matches `GetInventoryItemLink`'s practical behavior on
// NPCs (it crashes the client without this gate; addons that
// don't pre-filter the unit don't typically reach this path).
// Non-player object — no CGPlayer visible-items array at +0xE68.
// Gate on the object being a player, not merely player-controlled:
// a pet/totem/MC'd creature sets UNIT_FLAG_PLAYER_CONTROLLED but
// has no sub-struct there, so IsPlayerControlled would let it
// through and `ItemIDForOtherUnit` would deref garbage and crash
// (same root cause as pfUI issue #34). Returning nil also matches
// `GetInventoryItemLink`'s practical behavior on non-players.
return 0;
}
if (itemID == 0)
+8 -32
View File
@@ -45,6 +45,7 @@
#include "event/Custom.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "spell/CrowdControl.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -61,19 +62,15 @@ int NowMs() {
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
}
// Spell.dbc field readers (mirrors the small locals in Spell::Cast).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
const char *n =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELL_NAMES + locale * 4);
return (n != nullptr) ? n : "";
}
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const int iconID = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
@@ -87,30 +84,9 @@ int SpellSchoolIndex(const uint8_t *rec) {
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
}
// Classify a spell's control-loss type from its EffectApplyAuraName array, or
// null if it applies no control-loss aura. Priority-ordered (strongest control
// first) so a multi-effect spell resolves to one locType, matching retail.
const char *ClassifyControlLoss(const uint8_t *rec) {
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
return nullptr;
}
// The control-loss classifier lives in `Spell::CrowdControl::Classify` so the
// `C_UnitAuras` CROWD_CONTROL aura filter shares this hard-control set (that
// filter additionally counts snares, which are crowd control but not LoC).
// ---- School-interrupt lockout state (fed by SMSG_SPELL_COOLDOWN) -----------
@@ -203,7 +179,7 @@ int BuildList(LocEntry *out, int maxOut) {
const uint64_t playerGuid = Unit::Identity::PlayerGuid();
for (int i = 1; n < maxOut; ++i) {
const int slot = Aura::Data::FindNthSlot(
player, i, Aura::Data::Filter::Harmful, false);
player, i, Aura::Data::Filter::Harmful, Aura::Data::Match{});
if (slot < 0)
break;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
@@ -213,7 +189,7 @@ int BuildList(LocEntry *out, int maxOut) {
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
continue;
const char *locType = ClassifyControlLoss(rec);
const char *locType = Spell::CrowdControl::Classify(rec);
if (locType == nullptr)
continue;
+190
View File
@@ -0,0 +1,190 @@
// 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_Macro.CreateMacro` / `C_Macro.EditMacro` — create/edit macros whose
// icon is given as a texture STRING (a full `Interface\Icons\<name>` path
// or a bare `<name>` basename), not a `GetMacroIconInfo` index.
//
// C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
// C_Macro.EditMacro(index, name, iconTexture, body) -> index
//
// Why this exists: vanilla `CreateMacro`/`EditMacro` take an icon INDEX
// into the `GetMacroIconInfo` list, and that list omits `INV_*` item
// icons entirely — so item icons are unreachable through the stock API
// even though the engine stores and renders them fine. The macro struct
// keeps the icon as a bare basename at `entry + OFF_MACRO_ICON` and
// `GetMacroInfo` reads it back as `Interface\Icons\%s`, so ANY texture
// living under `Interface\Icons\` round-trips once you can write the
// basename directly. These two functions do exactly that, unblocking a
// pfUI macro-UI remodel that wants arbitrary icons.
//
// The legacy globals `CreateMacro`/`EditMacro` are left untouched
// (index-only) — no overloading; string-icon callers opt in via the
// `C_Macro` namespace.
//
// Implementation: both go straight to the engine's own workers
// (`FUN_MACRO_CREATE` / `FUN_MACRO_EDIT`, see [[src/Offsets.h]]), which do
// the bounded SStrCopy into the struct, re-run the primary-spell parser,
// set the persist-on-logout dirty flag, and refresh the action bars.
// We only translate the icon string to a basename and, for edits,
// preserve the entry's `local` flag (the edit worker rewrites it
// unconditionally).
//
// Icon input is STRING-only by design (that's the whole point — item
// icons have no index). A numeric icon arg is treated as "no icon" on
// create and "leave unchanged" on edit; callers who want the numeric
// index path already have the legacy globals.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Macro::Edit {
namespace {
using MacroCreate_t = uint32_t(__fastcall *)(const char *name,
const char *iconBasename,
const char *body, uint32_t local,
int perCharacter);
using MacroEdit_t = void(__fastcall *)(uint32_t macroID, const char *name,
const char *iconBasename,
const char *body, uint32_t flag);
using MacroIdToSlot_t = uint32_t(__fastcall *)(uint32_t macroID);
using MacroNameToSlot_t = uint32_t(__fastcall *)(const char *name);
using MacroSlotToEntry_t = uint8_t *(__fastcall *)(uint32_t slot0Based);
constexpr uint32_t kSlotMiss = 0xFFFFFFFFu;
// Last path component of `s` (after the final `\` or `/`). Turns
// "Interface\\Icons\\INV_Sword_25" and a bare "INV_Sword_25" alike into
// the basename the macro struct stores; the engine re-prefixes it with
// `Interface\Icons\` on read. Aliases into `s` — no copy.
const char *IconBasename(const char *s) {
const char *bn = s;
for (const char *p = s; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/')
bn = p + 1;
}
return bn;
}
// The icon to store, or nullptr (create: no icon; edit: leave unchanged).
// String-only — see the file header.
const char *IconArg(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
return IconBasename(Game::Lua::ToString(L, idx));
}
// A body/name string arg, or nullptr when absent (edit: leave unchanged).
const char *OptString(void *L, int idx) {
return Game::Lua::IsString(L, idx) ? Game::Lua::ToString(L, idx) : nullptr;
}
uint8_t *EntryForSlot(uint32_t slot0) {
auto fn = reinterpret_cast<MacroSlotToEntry_t>(
Offsets::FUN_MACRO_SLOT_TO_ENTRY);
return fn(slot0);
}
// C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
// Returns the new macro's 1-based slot, or nil when creation fails (empty
// name, or the per-scope 18-macro list is full).
int __fastcall Script_CreateMacro(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(
L, "Usage: C_Macro.CreateMacro(name, iconTexture, body, "
"isCharacterMacro)");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const char *icon = IconArg(L, 2);
const char *body = OptString(L, 3);
const int perCharacter = Game::Lua::ToBoolean(L, 4);
auto create = reinterpret_cast<MacroCreate_t>(Offsets::FUN_MACRO_CREATE);
const uint32_t macroID = create(name, icon, body, /*local*/ 0, perCharacter);
if (macroID == 0) {
Game::Lua::PushNil(L);
return 1;
}
auto idToSlot =
reinterpret_cast<MacroIdToSlot_t>(Offsets::FUN_MACRO_ID_TO_SLOT);
const uint32_t slot0 = idToSlot(macroID);
if (slot0 == kSlotMiss) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
return 1;
}
// C_Macro.EditMacro(index, name, iconTexture, body) -> index
// `index` is a 1-based slot number or a macro name. Any of name /
// iconTexture / body left nil is unchanged. Returns the macro's 1-based
// slot, or nil when `index` doesn't resolve to an existing macro.
int __fastcall Script_EditMacro(void *L) {
uint32_t slot0;
if (Game::Lua::IsNumber(L, 1)) {
slot0 = static_cast<uint32_t>(static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1);
} else if (Game::Lua::IsString(L, 1)) {
auto nameToSlot = reinterpret_cast<MacroNameToSlot_t>(
Offsets::FUN_MACRO_NAME_TO_SLOT);
slot0 = nameToSlot(Game::Lua::ToString(L, 1));
} else {
Game::Lua::Error(
L, "Usage: C_Macro.EditMacro(index, name, iconTexture, body)");
return 0;
}
// One validation covers both a bad slot number and a name miss:
// FUN_MACRO_NAME_TO_SLOT returns 0xFFFFFFFF on miss, which the slot
// resolver bounds-rejects to NULL just like an out-of-range index.
uint8_t *entry = EntryForSlot(slot0);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint32_t macroID = *reinterpret_cast<const uint32_t *>(entry);
// The edit worker rewrites entry+OFF_MACRO_LOCAL_FLAG unconditionally;
// read the current value and hand it back so an edit that only touches
// name/icon/body leaves the flag genuinely unchanged.
const uint32_t flag = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_MACRO_LOCAL_FLAG);
const char *name = OptString(L, 2);
const char *icon = IconArg(L, 3);
const char *body = OptString(L, 4);
auto edit = reinterpret_cast<MacroEdit_t>(Offsets::FUN_MACRO_EDIT);
edit(macroID, name, icon, body, flag);
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Macro", "CreateMacro",
&Script_CreateMacro);
Game::Lua::RegisterTableFunction("C_Macro", "EditMacro", &Script_EditMacro);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Macro::Edit
+1 -2
View File
@@ -46,7 +46,6 @@ namespace Macro::Spell {
namespace {
constexpr int OFF_SPELL_RANK = 0x204; // 9-locale char *[9] in Spell.dbc
using MacroSlotToEntry_t = const uint8_t *(__fastcall *)(unsigned slot0Based);
@@ -88,7 +87,7 @@ int __fastcall Script_GetMacroSpell(void *L) {
if (name == nullptr)
return 0;
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + locale * 4);
record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
Game::Lua::PushString(L, name);
// Rank slot is empty for most non-class spells (racials, procs,
+7 -1
View File
@@ -27,7 +27,13 @@ using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// 0xC) and returns true on first match. Mirrors the engine's loop in
// `Script_IsUnitOnQuest` (`0x004DFE10`).
bool UnitHasQuest(const uint8_t *unit, int target) {
if (target <= 0 || !Unit::Flags::IsPlayerControlled(unit))
// The quest list lives in the CGPlayer sub-struct at +0xE68, which
// only exists on real player objects. Gate on the object being a
// player, not merely player-controlled — a pet/totem/MC'd creature
// sets UNIT_FLAG_PLAYER_CONTROLLED but has no sub-struct there, so
// IsPlayerControlled would deref garbage and crash (same root cause
// as pfUI issue #34).
if (target <= 0 || !Unit::Flags::IsPlayerObject(unit))
return false;
auto *info = *reinterpret_cast<const uint8_t *const *>(
+10 -12
View File
@@ -19,13 +19,13 @@
// vs. on-next-swing abilities like Heroic Strike or Maul. We test
// against the engine constant directly.
//
// Ranged — both vanilla ranged auto-attacks (`75` Auto Shot, `5019`
// Shoot wand) set bit 1 of `Spell.dbc.Attributes` (the
// `SPELL_ATTR_AUTO_REPEAT` flag at value `0x02`). Verified empirically
// against the in-game cache: Attr `0x00050012` (Auto Shot) and
// `0x00000012` (Shoot) share that bit; on-next-swing Heroic Strike
// (`0x00050014`) and other ranged spells like Aimed Shot / Fireball do
// NOT. The attribute check naturally extends to any future
// Ranged — the two vanilla ranged auto-attacks (`75` Auto Shot, `5019`
// Shoot wand) are exactly the spells carrying SPELL_ATTR_EX2_AUTOREPEAT_FLAG
// (AttributesEx2 bit 5, `0x20`). Verified from Spell.dbc: Auto Shot and
// Shoot have Ex2 `0x20`; the on-cast ranged abilities (Aimed Shot 19434,
// Multi-Shot 2643) do NOT — they only set the generic RANGED bit
// (Attributes bit 1), which is why an earlier RANGED test wrongly matched
// them. The AUTOREPEAT flag is the exact signal and extends to any future
// auto-repeating spell a private server might add.
#include "Lookup.h"
@@ -42,8 +42,6 @@ namespace Spell::AutoAttack {
namespace {
constexpr int SPELL_AUTO_ATTACK = 6603;
constexpr int OFF_SPELL_ATTRIBUTES = 0x18;
constexpr uint32_t SPELL_ATTR_AUTO_REPEAT = 0x00000002;
bool IsMelee(int spellID) {
return spellID == SPELL_AUTO_ATTACK;
@@ -57,9 +55,9 @@ bool IsRanged(int spellID) {
static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
const uint32_t attr = *reinterpret_cast<const uint32_t *>(
rec + OFF_SPELL_ATTRIBUTES);
return (attr & SPELL_ATTR_AUTO_REPEAT) != 0;
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
return (attrEx2 & Offsets::SPELL_ATTR_EX2_AUTOREPEAT_FLAG) != 0;
}
int ReadSpellID(void *L) {
+10 -20
View File
@@ -89,13 +89,6 @@ using TickMs_t = uint32_t(__fastcall *)();
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
// Spell.dbc record field offsets (mirrors Spell::Info's locals).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
constexpr int OFF_ATTRIBUTES_EX2 = 0x24; // u32 AttributesEx2 flags
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
// cooking with a cast, etc.). The version-stable `isTradeskill` source:
// 3.3.5's Script_UnitCastingInfo reads bit 0x20 of Attributes (at +0x10
@@ -106,7 +99,6 @@ constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
// flat +500ms "aim time" to these (SpellEntry::GetCastTime). Auto-repeat
// shots (Auto Shot / Shoot, AttributesEx2 bit 0x20) are the one exclusion.
constexpr uint32_t SPELL_ATTR_RANGED = 0x2;
constexpr uint32_t SPELL_ATTR_EX2_AUTOREPEAT = 0x20;
struct TrackedSpell {
int spellID; // 0 = not casting / channeling
@@ -188,10 +180,10 @@ int CastTimeMs(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec != nullptr) {
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES);
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
const uint32_t attrEx2 =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX2);
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & SPELL_ATTR_EX2_AUTOREPEAT))
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & Offsets::SPELL_ATTR_EX2_AUTOREPEAT_FLAG))
ms += 500;
}
}
@@ -212,24 +204,25 @@ int ChannelDurationMs(int spellID) {
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
return *reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELL_NAMES + locale * 4);
}
bool IsTradeskill(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES) & SPELL_ATTR_TRADESPELL) != 0;
return (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
SPELL_ATTR_TRADESPELL) != 0;
}
// Spell icon texture path, or "" if there's no icon record. SpellIcon.dbc
// already stores the full "Interface\Icons\..." path (unlike item icons,
// which are bare filenames) — so it's used verbatim, no prefix.
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const int iconID = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
if (iconRec == nullptr)
return "";
const char *path = *reinterpret_cast<const char *const *>(iconRec + OFF_SUBRECORD_VALUE);
const char *path = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
return (path != nullptr) ? path : "";
}
@@ -340,12 +333,9 @@ const Game::HookAutoRegister _castStartHook{
// thing at a time). Regular casts back `UnitCastingInfo`; channels add real
// times to `UnitChannelInfo` (validated against the live +0x228 field).
constexpr int OFF_ATTRIBUTES_EX = 0x1C; // Spell.dbc AttributesEx
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
bool IsChannelSpell(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
SPELL_ATTR_EX_CHANNELED) != 0;
return (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED) != 0;
}
// Channel duration for a non-player caster — base (skipMod=1), since we
+1 -3
View File
@@ -19,8 +19,6 @@
namespace Spell::CastByID {
// Offset of the locale-resolved Name[9] table inside a Spell.dbc record.
static constexpr int OFF_NAME = 0x1E0;
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
static NameToSlot_t NameToSlot_o = nullptr;
@@ -48,7 +46,7 @@ static const char *LocaleNameForID(int spellID) {
return nullptr;
const int locale = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
return *reinterpret_cast<const char *const *>(record + OFF_NAME + locale * 4);
return *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
}
// Hook on the single name → spellbook-slot resolver
+10 -17
View File
@@ -29,9 +29,6 @@ namespace Spell::CastEvents {
namespace {
// Spell.dbc localized string arrays (9 locale slots, 4 bytes each).
constexpr int OFF_NAME = 0x1E0; // Name[9]
constexpr int OFF_RANK = 0x204; // Rank[9]
constexpr const char *kStart = "UNIT_SPELLCAST_START";
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
@@ -120,8 +117,8 @@ void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), type, spellID, guidNum);
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", castGuid, spellID,
LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
LocalizedField(rec, Offsets::OFF_SPELL_NAMES),
LocalizedField(rec, Offsets::OFF_SPELL_RECORD_RANK));
}
// SENT is the one event whose shape differs from the rest, matching modern:
@@ -138,8 +135,8 @@ void FireSent(int spellID, int guidNum, const char *target) {
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
Event::Custom::Fire(slot, "%s%s%s%d%s%s", "player", target ? target : "",
castGuid, spellID, LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
castGuid, spellID, LocalizedField(rec, Offsets::OFF_SPELL_NAMES),
LocalizedField(rec, Offsets::OFF_SPELL_RECORD_RANK));
}
// Fire a RETICLE event `(unitTarget, castGUID, spellID, spellName, rank)`.
@@ -158,8 +155,8 @@ void FireReticle(const char *eventName, int spellID) {
if (rec == nullptr)
return;
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", "", spellID,
LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
LocalizedField(rec, Offsets::OFF_SPELL_NAMES),
LocalizedField(rec, Offsets::OFF_SPELL_RECORD_RANK));
}
// spellID whose reticle we last reported active (0 = no reticle up).
@@ -257,15 +254,11 @@ int g_pendingChanSuccSpell = 0;
int g_pendingChanSuccTMs = 0;
constexpr int kChanSuccDeferMs = 500;
// Spell.dbc AttributesEx channel bits — same test Spell::Cast uses.
constexpr int OFF_ATTRIBUTES_EX = 0x1C;
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
bool IsChanneledSpell(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
return rec != nullptr &&
(*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
SPELL_ATTR_EX_CHANNELED) != 0;
(*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED) != 0;
}
int NowMs() {
@@ -390,8 +383,8 @@ void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
return;
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
const char *name = LocalizedField(rec, OFF_NAME);
const char *rank = LocalizedField(rec, OFF_RANK);
const char *name = LocalizedField(rec, Offsets::OFF_SPELL_NAMES);
const char *rank = LocalizedField(rec, Offsets::OFF_SPELL_RECORD_RANK);
for (int i = 0; i < n; ++i)
Event::Custom::Fire(slot, "%s%s%d%s%s", tokens[i], castGuid, spellID,
name, rank);
+1 -5
View File
@@ -52,10 +52,6 @@ namespace Spell::CastNoToggle {
namespace {
// Offset of the locale-resolved Name[9] table inside a Spell.dbc record
// (same value used by `Spell::Info`; duplicated here to keep this module
// header-free).
constexpr int OFF_NAME = 0x1E0;
using CastSpellByName_t = int(__fastcall *)(void *L);
using NameToSlot_t = int(__fastcall *)(const char *name, int *outBookType);
@@ -75,7 +71,7 @@ const char *LocaleName(int spellID) {
return nullptr;
const int locale = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
return *reinterpret_cast<const char *const *>(record + OFF_NAME + locale * 4);
return *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
}
// Case-insensitive name match for the auto-repeat-name gate. Walks
+61
View File
@@ -0,0 +1,61 @@
// 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 "spell/CrowdControl.h"
#include "Offsets.h"
#include "spell/Lookup.h"
namespace Spell::CrowdControl {
const char *Classify(const uint8_t *rec) {
if (rec == nullptr)
return nullptr;
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
return nullptr;
}
bool IsCrowdControl(uint32_t spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
return false;
// Crowd control is broader than loss of control: it also counts movement
// slows / snares, which `Classify` (the LoC set) deliberately excludes.
if (Classify(rec) != nullptr)
return true;
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == Offsets::SPELL_AURA_MOD_DECREASE_SPEED)
return true;
return false;
}
} // namespace Spell::CrowdControl
+40
View File
@@ -0,0 +1,40 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Crowd-control classification of a Spell.dbc record, shared by
// `C_LossOfControl` (which reports the control-loss type) and the
// `C_UnitAuras` `CROWD_CONTROL` aura filter (which only needs presence). One
// source of truth for "is this a hard control effect" so the two never drift.
namespace Spell::CrowdControl {
// Classifies a spell by its `EffectApplyAuraName` set into a control-loss
// type string, priority-ordered (strongest control first) so a multi-effect
// spell resolves to one type:
// POSSESS, CHARM, STUN, FEAR, CONFUSE, PACIFYSILENCE, SILENCE, PACIFY,
// ROOT, DISARM
// Returns nullptr if the spell applies no control-loss aura (`spellRecord`
// may be null → nullptr). Movement-only slows/snares are intentionally NOT
// classified — they are not a loss of control.
const char *Classify(const uint8_t *spellRecord);
// True iff `spellID` is a crowd-control aura. Broader than `Classify`: it
// also counts movement slows / snares (`MOD_DECREASE_SPEED`), which are crowd
// control but not loss of control. Backs the C_UnitAuras CROWD_CONTROL filter.
bool IsCrowdControl(uint32_t spellID);
} // namespace Spell::CrowdControl
+28 -39
View File
@@ -22,24 +22,14 @@
namespace Spell::Info {
// Spell.dbc record offsets (from BuildSpellTooltip / Script_GetSpellName / Script_GetSpellTexture).
static constexpr int OFF_ATTRIBUTES_EX = 0x1C;
static constexpr int OFF_CASTING_TIME_INDEX = 0x48;
static constexpr int OFF_POWER_TYPE = 0x7C;
static constexpr int OFF_MANA_COST = 0x80;
static constexpr int OFF_RANGE_INDEX = 0x90;
static constexpr int OFF_ICON_ID = 0x1D4;
static constexpr int OFF_NAME = 0x1E0;
static constexpr int OFF_RANK = 0x204;
// Spell.dbc Attributes (+0x18) and AttributesEx (+0x1C) flag bits we read.
// Both bits are 0x40, but on different fields:
// Attributes bit 6 = SPELL_ATTR_PASSIVE — passive spell (no cast bar,
// applies its effect as soon as learned/equipped)
// AttributesEx bit 6 = SPELL_ATTR_EX_FUNNEL_PERCENT — funnel channel
static constexpr int OFF_ATTRIBUTES = 0x18;
static constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40;
static constexpr uint32_t SPELL_ATTR_EX_FUNNEL = 0x40;
// GetSpellInfo's isFunnel means a health-funnel spell — AttributesEx2 (+0x20)
// bit 11 = SPELL_ATTR_EX2_HEALTH_FUNNEL. Verified from Spell.dbc: Health
// Funnel (755) Ex2=0x808 and Hellfire (1949) Ex2=0x800 carry it, while
// channeled non-funnels (Drain Life, Mind Flay, Arcane Missiles, Rain of
// Fire) do not. An earlier version read AttributesEx bit 6, which is
// CHANNELED_2 — false for Health Funnel itself and true for unrelated
// channels, so it never actually detected funnel spells.
static constexpr uint32_t SPELL_ATTR_EX2_HEALTH_FUNNEL = 0x800;
// Spell.dbc effect-target arrays. Each spell has 3 effects, each
// with an implicit target A and an implicit target B (the latter
// often 0). Used by IsSpellHarmful / IsSpellHelpful — vanilla has
@@ -48,9 +38,6 @@ static constexpr uint32_t SPELL_ATTR_EX_FUNNEL = 0x40;
// have it), so we classify by walking effect targets and checking
// whether any falls into a known hostile-target or friendly-target
// set. Same algorithm CMaNGOS uses in `SpellMgr::IsPositiveSpell`.
static constexpr int OFF_EFFECT_IMPLICIT_TARGET_A = 0x148; // uint32[3]
static constexpr int OFF_EFFECT_IMPLICIT_TARGET_B = 0x154; // uint32[3]
// Target IDs from vanilla 1.12's `SpellTarget` enum that mark a
// spell as hostile-targeted (covers single-target damage,
// debuffs, AoE damage, etc.). List is conservative — anything not
@@ -142,25 +129,26 @@ static bool ReadSpellInfo(int spellID, SpellInfoData &out) {
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
out.spellID = spellID;
out.name = *reinterpret_cast<const char *const *>(record + OFF_NAME + locale * 4);
out.rank = *reinterpret_cast<const char *const *>(record + OFF_RANK + locale * 4);
out.name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
out.rank = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
out.iconPath = nullptr;
const int iconID = *reinterpret_cast<const int *>(record + OFF_ICON_ID);
const int iconID = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_ICON_ID);
if (auto *iconRec = LookupSubRecord(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT, iconID)) {
out.iconPath = *reinterpret_cast<const char *const *>(iconRec + 4);
out.iconPath = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
}
out.cost = *reinterpret_cast<const int *>(record + OFF_MANA_COST);
out.cost = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_MANA_COST);
const uint32_t attrEx = *reinterpret_cast<const uint32_t *>(record + OFF_ATTRIBUTES_EX);
out.isFunnel = (attrEx & SPELL_ATTR_EX_FUNNEL) != 0;
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
out.isFunnel = (attrEx2 & SPELL_ATTR_EX2_HEALTH_FUNNEL) != 0;
out.powerType = *reinterpret_cast<const int *>(record + OFF_POWER_TYPE);
out.powerType = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_POWER_TYPE);
out.castTimeMs = 0;
const int castIndex = *reinterpret_cast<const int *>(record + OFF_CASTING_TIME_INDEX);
const int castIndex = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_CASTING_TIME_INDEX);
if (auto *castRec = LookupSubRecord(Offsets::VAR_SPELL_CAST_TIMES_RECORDS,
Offsets::VAR_SPELL_CAST_TIMES_COUNT, castIndex)) {
out.castTimeMs = *reinterpret_cast<const int *>(castRec + 4);
@@ -168,7 +156,7 @@ static bool ReadSpellInfo(int spellID, SpellInfoData &out) {
out.minRange = 0.0f;
out.maxRange = 0.0f;
const int rangeIndex = *reinterpret_cast<const int *>(record + OFF_RANGE_INDEX);
const int rangeIndex = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
if (auto *rangeRec = LookupSubRecord(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIndex)) {
out.minRange = *reinterpret_cast<const float *>(rangeRec + 4);
@@ -288,7 +276,7 @@ static int __fastcall Script_C_GetSpellName(void *L) {
return 0; // nil for unknown spellID
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(record + OFF_NAME + locale * 4);
const char *name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || *name == '\0')
return 0; // empty / no name in current locale → nil
Game::Lua::PushString(L, name);
@@ -301,12 +289,12 @@ static int __fastcall Script_C_GetSpellTexture(void *L) {
if (record == nullptr)
return 0;
const int iconID = *reinterpret_cast<const int *>(record + OFF_ICON_ID);
const int iconID = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_ICON_ID);
auto *iconRec = LookupSubRecord(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT, iconID);
if (iconRec == nullptr)
return 0;
const char *path = *reinterpret_cast<const char *const *>(iconRec + 4);
const char *path = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
if (path == nullptr || *path == '\0')
return 0;
Game::Lua::PushString(L, path);
@@ -327,7 +315,7 @@ static bool BuildSpellLink(int spellID, char *out, size_t outLen) {
if (record == nullptr)
return false;
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(record + OFF_NAME + locale * 4);
const char *name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || *name == '\0')
return false;
const int n = std::snprintf(out, outLen, "|cff71d5ff|Hspell:%d:0|h[%s]|h|r",
@@ -404,8 +392,9 @@ static int PushIsPassive(void *L, int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0;
const uint32_t attr = *reinterpret_cast<const uint32_t *>(record + OFF_ATTRIBUTES);
Game::Lua::PushBool(L, (attr & SPELL_ATTR_PASSIVE) != 0);
const uint32_t attr = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
Game::Lua::PushBool(L, (attr & Offsets::SPELL_ATTR_PASSIVE) != 0);
return 1;
}
@@ -534,9 +523,9 @@ static bool AnyEffectTarget(const uint8_t *record, Pred pred) {
if (record == nullptr)
return false;
auto *targetsA = reinterpret_cast<const uint32_t *>(
record + OFF_EFFECT_IMPLICIT_TARGET_A);
record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
auto *targetsB = reinterpret_cast<const uint32_t *>(
record + OFF_EFFECT_IMPLICIT_TARGET_B);
record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_B);
for (int i = 0; i < 3; ++i) {
if (pred(targetsA[i]) || pred(targetsB[i]))
return true;
+32 -33
View File
@@ -16,7 +16,11 @@
// `Spell.dbc.EffectImplicitTargetA[3]` and `EffectImplicitTargetB[3]`
// — an effect is "self-only" when both targets are either
// `TARGET_NONE` (0) or `TARGET_SELF` (1). Effects with a zero
// `Effect` code are skipped (unused effect slots).
// `Effect` code are skipped (unused effect slots). The record-scan core
// is `Spell::IsSelfBuff::IsSelfBuff` (see the header) so the aura layer
// can share it for sourceGUID inference.
#include "spell/IsSelfBuff.h"
#include "Game.h"
#include "Offsets.h"
@@ -31,9 +35,6 @@ namespace {
// Spell.dbc per-effect arrays. Each is an int32[3] in the record.
// Field-index derivation against the known `EffectApplyAuraName` at
// field 91 / +0x16C — the implicit-target pair sits at field 82/85.
constexpr int OFF_SPELL_EFFECT = 0xF4; // SPELL_EFFECT_* code
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148;
constexpr int OFF_SPELL_EFFECT_TARGET_B = 0x154;
constexpr int EFFECT_COUNT = 3;
// Vanilla 1.12 implicit-target codes (CMaNGOS `Targets` enum).
@@ -46,41 +47,39 @@ bool IsSelfOnlyTarget(int target) {
} // namespace
bool IsSelfBuff(uint32_t spellID) {
if (spellID == 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT, spellID);
if (rec == nullptr)
return false;
bool sawEffect = false;
for (int i = 0; i < EFFECT_COUNT; ++i) {
const int effect = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT + i * 4);
if (effect == 0)
continue;
sawEffect = true;
const int targetA = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A + i * 4);
const int targetB = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_B + i * 4);
if (!IsSelfOnlyTarget(targetA) || !IsSelfOnlyTarget(targetB))
return false;
}
return sawEffect;
}
static int __fastcall Script_IsSelfBuff(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
bool sawEffect = false;
for (int i = 0; i < EFFECT_COUNT; ++i) {
const int effect = *reinterpret_cast<const int *>(
rec + OFF_SPELL_EFFECT + i * 4);
if (effect == 0)
continue;
sawEffect = true;
const int targetA = *reinterpret_cast<const int *>(
rec + OFF_SPELL_EFFECT_TARGET_A + i * 4);
const int targetB = *reinterpret_cast<const int *>(
rec + OFF_SPELL_EFFECT_TARGET_B + i * 4);
if (!IsSelfOnlyTarget(targetA) || !IsSelfOnlyTarget(targetB)) {
Game::Lua::PushBool(L, false);
return 1;
}
}
Game::Lua::PushBool(L, sawEffect);
Game::Lua::PushBool(
L, spellID > 0 && IsSelfBuff(static_cast<uint32_t>(spellID)));
return 1;
}
+28
View File
@@ -0,0 +1,28 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Spell::IsSelfBuff {
// True iff every active effect of `spellID` targets only the caster — each
// effect's implicit target A and B is `TARGET_NONE` (0) or `TARGET_SELF` (1),
// read from `Spell.dbc.EffectImplicitTargetA/B[3]`. Such an aura can only have
// been self-cast, so a unit carrying one is definitionally its own source.
// False for an unknown spell or one with no active effect. Shared by the
// `C_Spell.IsSelfBuff` Lua binding and the `C_UnitAuras` sourceGUID inference.
bool IsSelfBuff(uint32_t spellID);
} // namespace Spell::IsSelfBuff
+32 -43
View File
@@ -84,37 +84,35 @@ int SpellBaseLevel(int spellID) {
record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL);
}
// Spell.dbc attribute fields/bits used to filter learnable-spell
// results down to what's actually user-facing.
// Spell.dbc attribute filter for the learnable-spell walk. Only the
// verified HIDDEN_CLIENTSIDE bit is used:
//
// Attributes (+0x18)
// bit 0x80 = HIDDEN_CLIENTSIDE — engine-internal helpers like
// "Defensive Stance Passive" (7376) that pair with
// a user-visible spell (Defensive Stance, 71) and
// never appear in the spellbook. Skip these.
// Attributes (+0x18) bit 0x80 = HIDDEN_CLIENTSIDE — engine-internal
// helpers like "Defensive Stance Passive" (7376) that pair with a
// user-visible spell (Defensive Stance, 71) and never appear in the
// spellbook. Skip these.
//
// AttributesEx2 (+0x24)
// bit 0x02000000 = HIDE_FROM_AUTOLEARN — the same gate Cata's
// GetCurrentLevelSpells checks
// (FUN_00911c60's `(spellData[+0x24] &
// 0x02000000) == 0`). Catches proc-only and
// engine-internal spells with a BaseLevel.
constexpr int OFF_SPELL_RECORD_ATTRIBUTES = 0x18;
constexpr int OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24;
// A prior version also tested AttributesEx3 (+0x24) bit 0x02000000 as a
// "hide from auto-learn" gate ported from a MoP function (FUN_00911c60).
// That was invalid on every count: FUN_00911c60 is outside 1.12's .text
// and its `+0x24` is not 1.12's AttributesEx3. Verified against Spell.dbc,
// the 1.12 bit is a sparse grab-bag (44 / 27925 records: passive procs,
// odd DoT ranks, the Night Elf priest Starshards line, Turtle customs) —
// not an auto-learn flag — so it could only wrongly hide legitimate
// learnable spells (Starshards carries it). Removed. The authoritative
// auto-learn signal is SkillLineAbility.learnOnGetSkill (col 9: 2 =
// race/class, 1 = profession, 0 = trainer-taught), not a Spell.dbc bit;
// this feature deliberately reports the broader "trainable at level" set,
// so it applies no learnOnGetSkill filter.
constexpr uint32_t SPELL_ATTR_HIDDEN_CLIENTSIDE = 0x80;
constexpr uint32_t SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN = 0x02000000;
bool SpellHiddenFromAutoLearn(int spellID) {
bool SpellHiddenFromSpellbook(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
const uint32_t attributes = *reinterpret_cast<const uint32_t *>(
record + OFF_SPELL_RECORD_ATTRIBUTES);
if ((attributes & SPELL_ATTR_HIDDEN_CLIENTSIDE) != 0)
return true;
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
record + OFF_SPELL_RECORD_ATTRIBUTES_EX2);
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
return (attributes & SPELL_ATTR_HIDDEN_CLIENTSIDE) != 0;
}
// ---- Target-level gate for ranked beneficial auras ----------------------
@@ -132,12 +130,6 @@ bool SpellHiddenFromAutoLearn(int spellID) {
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
// the spellLevel rule is the only client-readable target-level mechanism.)
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
@@ -178,7 +170,7 @@ bool IsPositiveRankTarget(uint32_t t) {
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
bool HasRankString(const uint8_t *record) {
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + LocaleIndex() * 4);
record + Offsets::OFF_SPELL_RECORD_RANK + LocaleIndex() * 4);
return rank != nullptr && *rank != '\0';
}
@@ -186,19 +178,19 @@ bool HasRankString(const uint8_t *record) {
// the spell isn't subject to the ranked-positive-aura target-level rule.
int RequiredTargetLevel(const uint8_t *record) {
const int spellLevel =
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_SPELL_LEVEL);
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
return 0;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & SPELL_ATTR_PASSIVE)
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & Offsets::SPELL_ATTR_PASSIVE)
return 0;
if (!HasRankString(record)) // single-rank spell → not gated
return 0;
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
auto *effect = reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_EFFECT);
auto *targetA =
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
bool positiveAura = false;
for (int i = 0; i < 3; ++i) {
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
@@ -229,13 +221,13 @@ int __fastcall Script_GetSpellLevelInfo(void *L) {
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_SPELL_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_MAX_LEVEL)));
return 3;
}
@@ -425,12 +417,9 @@ int __fastcall Script_GetCurrentLevelSpells(void *L) {
// GetCurrentLevelSpells excludes them too.
if (TalentSpellSet().count(spellID) != 0)
continue;
// Skip spells flagged "hide from auto-learn list" — the
// same AttributesEx2 bit Cata's GetCurrentLevelSpells
// checks (FUN_00911c60). Catches engine-internal or proc-
// only spells that have a BaseLevel but aren't meant to
// surface as learnable.
if (SpellHiddenFromAutoLearn(spellID))
// Skip engine-internal spellbook-hidden spells (HIDDEN_CLIENTSIDE):
// they can carry a BaseLevel but never surface as learnable.
if (SpellHiddenFromSpellbook(spellID))
continue;
if (!seen.insert(spellID).second)
continue;
+4 -3
View File
@@ -51,9 +51,10 @@ float Apply(const uint8_t *spellRecord, int op, float base) {
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if (familyName == 0 || (attrEx2 & Offsets::SPELL_ATTR_EX2_NO_SPELL_MODS) != 0)
const uint32_t attrEx3 = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3);
if (familyName == 0 ||
(attrEx3 & Offsets::SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS) != 0)
return base;
if (familyName != PlayerSpellFamily())
return base;
+2 -4
View File
@@ -49,8 +49,6 @@ namespace {
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
constexpr int OFF_POWER_TYPE = 0x7C; // int PowerType
constexpr int OFF_MANA_COST_PERCENT = 0x270; // int, % of base resource (0 = flat)
uint32_t EffectiveCost(int spellID) {
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
@@ -66,8 +64,8 @@ int __fastcall Script_GetSpellPowerCost(void *L) {
if (cost == 0xFFFFFFFF)
return 0; // engine error (no player context, etc.)
const int powerType = *reinterpret_cast<const int *>(rec + OFF_POWER_TYPE);
const int costPercent = *reinterpret_cast<const int *>(rec + OFF_MANA_COST_PERCENT);
const int powerType = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_POWER_TYPE);
const int costPercent = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_MANA_COST_PERCENT);
if (cost == 0 && costPercent == 0)
return 0; // nil — no resource cost
+1 -2
View File
@@ -23,7 +23,6 @@ namespace {
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets (verified,
// documented in CLAUDE.md under "Sub-DBC record layouts").
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
@@ -42,7 +41,7 @@ bool SpellIDHasRange(int spellID) {
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
+104
View File
@@ -0,0 +1,104 @@
// 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.GetSpellDispelType` / `IsNextMeleeSpell` / `ResetsMeleeSwing` —
// read raw Spell.dbc fields straight off the record so pfUI can drop its
// nampower `GetSpellRecField` dependency.
//
// The Spell.dbc record fields we read live in `Offsets.h` (the single source
// of truth for the record layout): `OFF_SPELL_DISPEL_TYPE` (+0x10),
// `OFF_SPELL_RECORD_ATTRIBUTES` (+0x18), `OFF_SPELL_RECORD_ATTRIBUTES_EX2`
// (+0x20), `OFF_SPELL_RECORD_INTERRUPT_FLAGS` (+0x54).
//
// Melee rules (server core, Spell.h / Spell.cpp):
// on-next-swing = Attributes & (0x04 | 0x400) — two bits (Spell.h:434)
// resets swing = (InterruptFlags & 0x08) && !(AttributesEx2 & 0x20000)
// (Spell.cpp:3964 + Spell.h:441)
#include "Arg.h"
#include "Lookup.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Spell::RecordFields {
namespace {
// Attributes bits 0x04 (SPELL_ATTR_ON_NEXT_SWING) and 0x400
// (SPELL_ATTR_ON_NEXT_SWING_2) both mark an on-next-melee-swing ability
// (server Spell.h:434 tests the pair).
constexpr uint32_t SPELL_ATTR_ON_NEXT_SWING = 0x04 | 0x400;
// InterruptFlags bit 0x08 = SPELL_INTERRUPT_FLAG_AUTOATTACK.
constexpr uint32_t SPELL_INTERRUPT_AUTOATTACK = 0x08;
// AttributesEx2 bit 0x20000 = NOT_RESET_AUTO_ACTIONS — exempts the spell
// from the swing reset the AUTOATTACK interrupt flag would otherwise cause.
constexpr uint32_t SPELL_ATTR_EX2_NOT_RESET_AUTO_ACTIONS = 0x20000;
uint32_t ReadField(const uint8_t *rec, int offset) {
return *reinterpret_cast<const uint32_t *>(rec + offset);
}
} // namespace
// C_Spell.GetSpellDispelType(spellId) -> number. 0 for a non-dispellable
// spell or an unknown ID.
static int __fastcall Script_GetSpellDispelType(void *L) {
const uint8_t *rec = Spell::Lookup::RecordForID(Spell::Arg::ResolveSpellID(L, 1));
Game::Lua::PushNumber(
L, rec != nullptr
? static_cast<double>(ReadField(rec, Offsets::OFF_SPELL_DISPEL_TYPE))
: 0.0);
return 1;
}
// C_Spell.IsNextMeleeSpell(spellId) -> bool. True for on-next-swing abilities
// (Heroic Strike, Maul, Cleave, Raptor Strike, Slam).
static int __fastcall Script_IsNextMeleeSpell(void *L) {
const uint8_t *rec = Spell::Lookup::RecordForID(Spell::Arg::ResolveSpellID(L, 1));
const bool onNextSwing =
rec != nullptr &&
(ReadField(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) & SPELL_ATTR_ON_NEXT_SWING) != 0;
Game::Lua::PushBool(L, onNextSwing);
return 1;
}
// C_Spell.ResetsMeleeSwing(spellId) -> bool. True when casting the spell
// resets the melee swing timer (AUTOATTACK interrupt flag set and the
// NOT_RESET_AUTO_ACTIONS exemption clear).
static int __fastcall Script_ResetsMeleeSwing(void *L) {
const uint8_t *rec = Spell::Lookup::RecordForID(Spell::Arg::ResolveSpellID(L, 1));
const bool resets =
rec != nullptr &&
(ReadField(rec, Offsets::OFF_SPELL_RECORD_INTERRUPT_FLAGS) &
SPELL_INTERRUPT_AUTOATTACK) != 0 &&
(ReadField(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2) &
SPELL_ATTR_EX2_NOT_RESET_AUTO_ACTIONS) == 0;
Game::Lua::PushBool(L, resets);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellDispelType",
&Script_GetSpellDispelType);
Game::Lua::RegisterTableFunction("C_Spell", "IsNextMeleeSpell",
&Script_IsNextMeleeSpell);
Game::Lua::RegisterTableFunction("C_Spell", "ResetsMeleeSwing",
&Script_ResetsMeleeSwing);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::RecordFields
+1 -6
View File
@@ -29,11 +29,6 @@ namespace Spell::Subtext {
namespace {
// `Spell.dbc.Rank[9]` — same 9-slot locale-string array shape as the
// adjacent `Name[9]` field. Defined locally; sibling modules
// (`Info.cpp`, `Tooltip.cpp`, `macro/Spell.cpp`) carry the same
// constant — promoting to `Offsets.h` is a future refactor.
constexpr int OFF_SPELL_RANK = 0x204;
} // namespace
@@ -46,7 +41,7 @@ static int __fastcall Script_GetSpellSubtext(void *L) {
const char *subtext = DBC::LocalizedField(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID),
OFF_SPELL_RANK);
Offsets::OFF_SPELL_RECORD_RANK);
if (subtext == nullptr) {
Game::Lua::PushNil(L);
return 1;
+5 -7
View File
@@ -24,11 +24,9 @@ namespace Spell::Tooltip {
// pulled from Spell::Info — that module keeps these as `static
// constexpr` privates and exposing them just for two readers here
// would force a header change.
static constexpr int OFF_SPELL_NAME = 0x1E0; // localized char *[9]
static constexpr int OFF_SPELL_RANK = 0x204; // localized char *[9]
using BuildSpellTooltip_t = void(__thiscall *)(void *thisObj, int spellID, int arg2, int arg3,
int isPet, int arg5, int arg6, int arg7);
int isPet, int showRank, int arg6, int arg7);
void ShowByID(void *L, int spellID) {
if (spellID <= 0)
@@ -38,7 +36,7 @@ void ShowByID(void *L, int spellID) {
return;
auto BuildSpellTooltip =
reinterpret_cast<BuildSpellTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, 0, 0, 0);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, /*showRank=*/1, 0, 0);
}
static int __fastcall Script_GameTooltipSetSpellByID(void *L) {
@@ -94,7 +92,7 @@ void AppendByID(void *L, int spellID) {
return;
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_NAME + locale * 4);
record + Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || name[0] == '\0')
return;
@@ -154,9 +152,9 @@ static int __fastcall Script_GameTooltipGetSpell(void *L) {
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_NAME + locale * 4);
record + Offsets::OFF_SPELL_NAMES + locale * 4);
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + locale * 4);
record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
if (name == nullptr)
return 0;
+3 -1
View File
@@ -17,7 +17,9 @@ namespace Spell::Tooltip {
// Resolves the `GameTooltip` self at Lua stack[1] (via the
// engine's `FrameScript_PushObject`/`FrameScript_GetObject` chain)
// and dispatches the engine's spell-tooltip builder for `spellID`.
// and dispatches the engine's spell-tooltip builder for `spellID`,
// with the spell's rank shown in the top-right corner of the title
// line (matching `SetAction`/`SetPetAction`; native `SetSpell` omits it).
// Silent no-op if `spellID <= 0` or stack[1] isn't a frame object.
//
// Used by `GameTooltip:SetSpellByID` directly, and by
+2 -4
View File
@@ -29,8 +29,6 @@ using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
// Spell.dbc record field offsets we read for usability. Fully documented
// in CLAUDE.md.
constexpr int OFF_SPELL_POWER_TYPE = 0x7C; // int8: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost (fallback only)
// Returns true if the player knows `spellID` per the engine's spell-
// knowledge bitmap at `[VAR_PLAYER_SPELL_BITMAP]`. Same check
@@ -179,12 +177,12 @@ Usability ComputeUsability(void *L, int spellID) {
// resolve a cost (shouldn't happen here: the player descriptor
// resolved above, so it has a player context).
const int powerType = static_cast<int>(*reinterpret_cast<const int8_t *>(
record + OFF_SPELL_POWER_TYPE));
record + Offsets::OFF_SPELL_RECORD_POWER_TYPE));
if (powerType >= 0 && powerType <= 4) {
auto getCost = reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST);
uint32_t cost = getCost(spellID, 0 /* local player */);
if (cost == 0xFFFFFFFF)
cost = *reinterpret_cast<const uint32_t *>(record + OFF_SPELL_MANA_COST);
cost = *reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MANA_COST);
if (cost > 0) {
const int currentPower = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + powerType * 4);
+7 -14
View File
@@ -56,13 +56,6 @@ namespace Totem::Tracker {
namespace {
// Spell.dbc record field offsets (see spell/Info.cpp for the shared set).
constexpr int OFF_EFFECT = 0xF4; // int32[3] — SPELL_EFFECT_*
constexpr int OFF_TOTEM_TOOL = 0xA0; // int32[2] — Totem (required tool item IDs)
constexpr int OFF_MISC_VALUE = 0x1A8; // int32[3] — EffectMiscValue
constexpr int OFF_ICON_ID = 0x1D4; // int32 — SpellIconID
constexpr int OFF_NAME = 0x1E0; // char*[9] locale array
// SUMMON_TOTEM_SLOT1..4 spell effects (verified against Spell.dbc: Searing
// 87/Fire, Stoneskin 88/Earth, Healing Stream 89/Water, Windfury 90/Air).
constexpr int kEffectSummonTotemSlot1 = 87;
@@ -101,7 +94,7 @@ uint32_t NowMs() {
// The totem tool item a spell requires (`Totem[0]`), or 0.
uint32_t TotemToolItem(const uint8_t *rec) {
const int32_t t = *reinterpret_cast<const int32_t *>(rec + OFF_TOTEM_TOOL);
const int32_t t = *reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELL_RECORD_TOTEM);
return t > 0 ? static_cast<uint32_t>(t) : 0;
}
@@ -131,7 +124,7 @@ void ScanTools() {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
continue;
const auto *eff = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
const auto *eff = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELL_RECORD_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e < kEffectSummonTotemSlot1 || e > kEffectSummonTotemSlot4)
@@ -413,10 +406,10 @@ int __fastcall Script_GetTotemInfo(void *L) {
*reinterpret_cast<const int *>(static_cast<uintptr_t>(
Offsets::VAR_LOCALE_INDEX));
const char *n = *reinterpret_cast<const char *const *>(
rec + OFF_NAME + locale * 4);
rec + Offsets::OFF_SPELL_NAMES + locale * 4);
if (n != nullptr)
name = n;
icon = IconPath(*reinterpret_cast<const int *>(rec + OFF_ICON_ID));
icon = IconPath(*reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_ICON_ID));
}
Game::Lua::PushBool(L, haveTotem);
@@ -563,7 +556,7 @@ int __fastcall Script_GameTooltipSetTotem(void *L) {
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || name[0] == '\0')
return 0;
@@ -630,8 +623,8 @@ void OnPlayerSpellGo(uint32_t spellID) {
if (rec == nullptr)
return;
const auto *effects = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
const auto *misc = reinterpret_cast<const int32_t *>(rec + OFF_MISC_VALUE);
const auto *effects = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELL_RECORD_EFFECT);
const auto *misc = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int eff = effects[i];
if (eff < kEffectSummonTotemSlot1 || eff > kEffectSummonTotemSlot4)
+23 -6
View File
@@ -34,14 +34,23 @@ bool IsPlayerControlled(const uint8_t *unit) {
return (unitFlags & Offsets::UNIT_FLAG_PLAYER_CONTROLLED) != 0;
}
bool IsPlayerObject(const uint8_t *unit) {
if (unit == nullptr)
return false;
return *reinterpret_cast<const int *>(unit + Offsets::OFF_CGOBJECT_TYPE_ID) ==
Offsets::OBJECT_TYPE_PLAYER;
}
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Reads a PLAYER_FLAGS bit for any player-controlled unit. Path:
// - Resolve unit; gate on `UNIT_FLAG_PLAYER_CONTROLLED` to avoid
// reading the +0xE68 pointer on a creature/NPC (it's uninitialized
// for non-player units).
// Reads a PLAYER_FLAGS bit for a player. Path:
// - Resolve unit; gate on the object actually being a player
// (`OBJECT_TYPE_PLAYER`) so we never read the +0xE68 CGPlayer
// sub-struct on a non-player object. UNIT_FLAG_PLAYER_CONTROLLED is
// insufficient — pets/totems/MC'd creatures set it but have no
// sub-struct there.
// - Read `[unit + 0xE68] + 0x08` — the unit's CGPlayer-side info
// struct, where byte +0x08 is PLAYER_FLAGS. Same struct
// `Script_IsResting` reads at +0x05 bit (RESTING = 0x20) for the
@@ -64,7 +73,10 @@ bool TestPlayerFlag(void *L, uint32_t flagMask) {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (!IsPlayerControlled(unit))
// PLAYER_FLAGS lives in the CGPlayer sub-struct — gate on the object
// actually being a player, not merely player-controlled (pets/totems
// set that flag but have no sub-struct → garbage +0xE68 deref).
if (!IsPlayerObject(unit))
return false;
auto *playerInfo = *reinterpret_cast<const uint8_t *const *>(
@@ -134,7 +146,12 @@ int __fastcall Script_UnitIsFeignDeath(void *L) {
// Returns 0 if the unit isn't player-controlled, isn't a player, or
// hasn't had its guild data synced.
uint32_t ReadGuildKey(const uint8_t *unit) {
if (!IsPlayerControlled(unit))
// The guild key lives in the CGPlayer sub-struct at +0xE68, which
// only exists on real player objects. A player-controlled *creature*
// (pet, totem, MC'd mob) has UNIT_FLAG_PLAYER_CONTROLLED set but no
// sub-struct, so gating on IsPlayerControlled here would deref garbage
// and crash (issue: UnitIsInMyGuild on a nameplate pet/totem).
if (!IsPlayerObject(unit))
return 0;
auto *info = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGPLAYER_INFO);
+22 -5
View File
@@ -16,14 +16,31 @@ namespace Unit::Flags {
// Tests `UNIT_FIELD_FLAGS & UNIT_FLAG_PLAYER_CONTROLLED` on the unit's
// `m_objectFields` descriptor — same predicate
// `Script_UnitPlayerControlled` (`0x00516410`) implements. Required
// gate before dereferencing the CGPlayer-side sub-struct at
// `unit + OFF_CGPLAYER_INFO` (= +0xE68) because that slot is
// uninitialized for non-player CGUnit subclasses (CGCreature_C
// instances) and reading it AVs on the subsequent deref.
// `Script_UnitPlayerControlled` (`0x00516410`) implements. True for
// players AND their pets/totems/guardians/companions/charms (the
// server sets this flag on all of them — see Turtle
// `UnitDefines.h` UNIT_FLAG_PLAYER_CONTROLLED).
//
// NOT a safe gate for reading the CGPlayer sub-struct at
// `unit + OFF_CGPLAYER_INFO` (= +0xE68): a player-controlled *creature*
// (pet/totem/MC'd mob) is a CGCreature_C with no such sub-struct, so
// its +0xE68 slot is garbage-but-non-null and the subsequent deref AVs
// (pfUI issue #34 — `UnitIsInMyGuild` on a nameplate pet). Use
// `IsPlayerObject` for that. This predicate is for minion/ownership
// classification, where the broad flag is what's wanted.
//
// Returns `false` for `nullptr` units and units with no
// descriptor pointer yet (transient pre-spawn / out-of-sync state).
bool IsPlayerControlled(const uint8_t *unit);
// True only for real player objects (`OBJECT_TYPE_PLAYER`), read from
// the base CGObject `m_objectType` field at `[obj + 0x14]`
// (`OFF_CGOBJECT_TYPE_ID`). This is the correct gate before reading the
// CGPlayer sub-struct at `unit + OFF_CGPLAYER_INFO` (guild key,
// PLAYER_FLAGS, visible items, quest list) — that sub-struct exists only
// on player objects, never on player-controlled creatures.
//
// Returns `false` for `nullptr` units.
bool IsPlayerObject(const uint8_t *unit);
} // namespace Unit::Flags