Compare commits

...
65 Commits
Author SHA1 Message Date
Brues b497bb7fb0 fix(attributes): run macrotext as text, not through name-based RunMacro
The secure "macro" action read both `macrotext` (raw text) and the
deprecated `macro` (saved-macro name) into one buffer and ran whichever
through the global RunMacro. RunMacro is name-based -- stock resolves an
index, and SuperCleveRoidMacros' RunMacro calls GetMacroIndexByName --
so a macrotext value like "/cast [hp:<90] Holy Light" resolved to no
macro and silently did nothing. Because the global existed, the native
fallback never ran.

Split the two by their real semantics: macrotext runs through the chat
parser (RunMacroTextC), so each line dispatches via SlashCmdList and
picks up addon slash hooks like SuperCleveRoidMacros' conditional /cast;
the name form keeps preferring RunMacro. Fixes click-cast macros with
conditionals.
2026-08-12 00:32:28 -05:00
Brues 4d5fe8d41b feat(spellbook): add C_SpellBook.GetSpellBookItemInfo
Backport C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank) -> table,
reusing Spell::Info's DBC reader. slotIndex is 1-based across the book;
spellBank is Enum.SpellBookSpellBank (0 = Player, 1 = Pet), resolved via
Spell::Lookup::SpellbookSlotToID. Returns nil for an empty/out-of-range
slot, matching retail.

Fields map from Spell.dbc: itemType is Spell (player) or PetAction (pet)
- vanilla's book holds only real spells, so None/FutureSpell/Flyout never
occur; actionID == spellID (no override system); name/subName from the
localized Name/Rank; isPassive from Attributes; isOffSpec always false.
iconID is the icon PATH string (vanilla has no fileID), the same
deviation as C_Spell.GetSpellInfo.iconID; skillLineIndex is omitted.

Also register the two Enum tables the return contract references
(non-destructively, like the other Enum.* additions) so
info.itemType == Enum.SpellBookItemType.Spell comparisons work:
Enum.SpellBookSpellBank and Enum.SpellBookItemType.

Document in docs/API.md (SpellBook section + both Enum entries) and list
in README.
2026-08-11 01:28:31 -05:00
Brues 46cae9d465 fix(unitmenu): stop stacking Focus/Cancel when a submenu opens
ClassicAPI_ToggleUnitMenu's initialize closure ran the same level-1
customization at every level. When the mouse crossed a nested entry
(Loot Method / Loot Threshold / Raid Target Icon) the menu builds its
level-2 submenu and re-invokes initialize; our AddFocusButton /
AddCancelButton call UIDropDownMenu_AddButton with no level, which
defaults to level 1 (not the current level), appending onto the still-
open level-1 list. Level 1 is not reset while a submenu rebuilds, so the
list grew every hover until "Too many buttons in UIDropDownMenu:
ClassicAPIUnitMenuDropDown", pushing the real entries out of reach.

Make initialize level-aware: at level >= 2 hand off to UnitPopup_ShowMenu
(it resolves the submenu from UIDROPDOWNMENU_MENU_VALUE and adds its own
Cancel); do the scratch-copy + Focus/Cancel append only at level 1 -
mirroring how Blizzard's own unit dropdowns init.
2026-08-10 13:08:58 -05:00
Brues 93360ce482 fix(cast): don't start target-selection casts at the reticle step
Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
FUN_CAST_START_SET before Spell_C_TargetSpell raises the reticle, so a
target-selection spell (Disenchant, ground-target AoE) stamps g_cast at
the green-cursor step - seconds before the item/ground is clicked and the
cast actually begins. The later click re-enters Spell_C_CastSpell only to
hit its "already this spell" short-circuit, so the writer never re-fires
for the real start. Result: UNIT_SPELLCAST_START (and a UnitCastingInfo
cast bar) fired far too early, and a cancel fired a phantom
START/INTERRUPTED/STOP for a cast that never happened.

Drop the client stamp in OnWorldTick while the reticle is up for that
spell. On selection the server's SMSG_SPELL_START re-stamps g_cast via
the fresh-stamp path (it is 0), so START fires with the correct time and
the SENT castGUID; on cancel it stays cleared. Gated on !g_castFromServer
(never touches the server re-stamp) and on g_cast.spellID ==
VAR_PENDING_CAST_SPELL (only the spell whose reticle is actually up).
Normal casts have no targeting phase and are unaffected.
2026-08-10 00:11:21 -05:00
Brues b879730dfb ci: attach ClassicAPI.map to GitHub releases
Add build/Release/ClassicAPI.map to the release step's asset list so
every vX.Y.Z release ships the linker map beside the DLL. It resolves
the `0001:offset` logical addresses in a shipped build's ERROR #132
crash dump back to function names for that exact build. Dev-build
artifacts stay DLL-only.
2026-08-09 23:27:53 -05:00
Brues 9cbf185b3c fix(frame): make ScriptArgs global save/restore longjmp-safe
RunModern reimplements the frame-script runner tail, saving the
this/arg1..argN globals it clobbers so it can restore them after the
handler pcall. The old save used luaL_ref into the registry, mirroring
the engine's own FUN_00704f10 - but any push before the protected call
can longjmp (out-of-memory being the real case), which unwinds past the
C frame and skips the restore: the refs leak and the globals stay
clobbered for later handlers.

Save the old values on the Lua stack instead. An unwinding error resets
L->top past them for free, so there is nothing to leak and no clobbered
state to strand. This also drops the per-call luaL_ref/luaL_unref churn
and the per-arg re-read from globals on the OnUpdate hot path; the call
args are pushed directly from a pure-C parse of the vararg buffer.

The stack save is deeper than the registry save (~2N slots live at
once), so CheckStack up front; if it cannot grow (or there is no Lua
state) RunModern returns false before any side effect and the thunk
falls back to the original engine runner, never double-running.

Behavior unchanged: default-off passthrough, and identical
(self, [event,] arg1..argN) semantics when enabled.
2026-08-09 23:26:47 -05:00
Brues 68f6a687cc build: emit linker .map for crash-dump triage
Add /MAP to the shared-linker flags so the Release build produces
ClassicAPI.map, which resolves the `0001:offset` logical addresses in
WoW's ERROR #132 crash dumps back to function names. Linker-only output;
no effect on code layout.
2026-08-09 23:26:47 -05:00
Brues 2d5ecdc493 refactor(spell): identify Ferocious Bite structurally, share IsFitToFamily
Carnage's Ferocious Bite gate was a hardcoded list of five rank IDs. Replace
it with the server's own structural test (IsDruidFerociousBite): druid family
+ the Rip/Bite family flag + a direct-damage effect (the damage effect is what
separates Bite from Rip, its DoT sibling that shares the flag). Rank-proof and
covers any Turtle-added rank with no ID list. Verified against the client
Spell.dbc: all five FB ranks are {family 7, flag 0x800000, Effect[0]
SCHOOL_DAMAGE}; Rip shares the flag but is APPLY_AURA, and Rake carries a
different flag.

The family + flag-bit test is the client-side form of the server's
SpellEntry::IsFitToFamily, and Aura::Source::AffectedMatchesRaw read the same
two fields inline, so extract Spell::Lookup::IsFitToFamily and route both
through it. Adds SPELL_EFFECT_SCHOOL_DAMAGE (= 2, verified vs the server's
SpellDefines.h).
2026-08-09 02:52:34 -05:00
Brues 783ffd05bf feat(aura): Carnage Rip/Rake refresh + signal duration-mod changes
Turtle's Carnage refreshes the caster's Rip and Rake off Ferocious Bite,
behind a per-combo-point roll whose outcome the client only sees as the
combo point the proc returns. A src/turtle/Carnage watcher arms on a
Ferocious Bite send and confirms on the first combo-point rise, then
refreshes the cached auras. Verified against tortoise-wow
spell_druid_carnage: icons 108 (Rip) / 494 (Rake), family bits 0x800000 /
0x1000, caster scoping, and RefreshHolder (restore the aura's own applied
duration).

A cache duration edit has no engine packet behind it, so nothing re-fired
UNIT_AURA and event-driven aura bars never saw the change. The trigger mods
(ApplyMod) and the Carnage refresh now enqueue the affected unit, and the
WorldTick flush re-broadcasts UNIT_AURA via the engine's own per-token
signal (FUN_UNIT_EVENT_BROADCAST), deferred off the SMSG_SPELL_GO subscriber.

Also move the two static Turtle duration mods (Conflagrate -> Immolate,
Molten Blast -> Flame Shock) from Lua into src/turtle/DurationMods via a new
C++ entry Aura::Source::AddDurationMod, and drop the now-unused exact-spellID
RegisterAuraDurationModifier Lua export (RegisterAuraDurationModifierByTrigger
stays for Shadow Weaving).

Based on the approach from @fuffc in #15.

Closes #15
2026-08-09 02:40:47 -05:00
Brues f559eb4d07 docs(api): move UNIT_SPELLCAST_* events into the Events section, fix TOC link
The TOC entry pointed at #unit_spellcast_-events-player, but the heading
generates #unit_spellcast_-events (no -player suffix) — broken link. Move
both the TOC entry and the content block out of the Spell section and into
the Events section where the other event docs live, and correct the anchor.
2026-08-08 17:10:11 -05:00
fuffc 2de7176b94 fix(aura): key the combo-point capture by spellID so a proc can't eat it (#14)
The capture was a single slot, and ConsumeCapturedCP cleared it before
testing whether the spellID even matched. Every player-cast aura spell
reaches TryComboScaledMs, so any aura landing between a finisher's
CMSG_CAST_SPELL and its own SMSG_SPELL_GO consumed the snapshot and left the
finisher with no combo points — falling back to the 0-CP base duration.

Not a rare race. Turtle's rogue Taste for Blood casts spell 52529 on the
player off the same Rupture, and its SPELL_GO arrives first, so Rupture
reported a static 12s (its 6s 0-CP base plus the talent) at every combo
count. Untalented, with no proc to clobber the slot, the same cast scaled
correctly — which is what made this look like a modifier bug rather than a
capture one.

Key the captures by spellID in a small ring and consume only the matching
entry. The comment claiming one cast is in flight at a time was reasoning
about finishers alone; the funnel sees every cast.
2026-08-08 16:27:57 -05:00
Brues 13008ab8aa refactor(item): centralize passive item-cache peek in Item::PeekRecord
Add item/Record.{h,cpp} with Item::PeekRecord(itemID) -- the passive
ItemStats_C cache peek (null callback, no network query). Route the three
duplicate implementations through it:
  - the per-module GetItemRecord_t typedef + fn(cache,id,&zero,nullptr,
    nullptr,0) call in ~25 read-only item accessors
  - Item::StatAccum::FetchRecord (deleted; 7 callers repointed)
  - item/Tooltip.cpp's local PeekItemRecord (deleted)

Behavior-identical: every converted call was already a passive peek with
identical args (dedup flag is a no-op when the callback is null).

item/Data.cpp is deliberately untouched -- it owns the ACTIVE fetch
(CacheFetch passes a real callback to fire SMSG_ITEM_QUERY_SINGLE, the
load-bearing cache-race path in CLAUDE.md) and keeps its own typedef.
2026-08-08 15:35:07 -05:00
Brues 0df8885045 refactor(offsets): collapse duplicate object type-mask enum (OBJ_TYPE_* -> TYPEMASK_*)
OBJ_TYPE_* was a partial second naming of the same object-type bits as
the complete TYPEMASK_* set (OBJ_TYPE_ITEM=2=TYPEMASK_ITEM, ... GAMEOBJECT
=0x20 both) -- a single-source-of-truth violation. Repoint all ~18
references to TYPEMASK_* (identical values, behavior-preserving) and
delete the OBJ_TYPE_* definitions, folding their derivation notes
(bitmask semantics, FUN_004F8DB0 for PLAYER, FUN_0052AA20 for GAMEOBJECT,
FUN_00529FE0 SetUnit) into the surviving TYPEMASK_* comment.
2026-08-08 15:12:55 -05:00
Brues 8fa344a084 refactor(item): name CGItem deref idioms via Item::InstanceBlock / ObjectFields
Add item/CGItem.{h,cpp} with two null-safe accessors for a CGItem's two
descriptor-like pointers:
  InstanceBlock(cgItem) -> CGItem + 0x08 (item GUID @ +0, itemID @ +0x0C)
  ObjectFields(cgItem)  -> CGItem + 0x114 (stack/flags/charges/durability/...)

Replace the 30 hand-written `*reinterpret_cast<const uint8_t *const *>(x +
OFF_ITEM_INSTANCE_BLOCK/OFF_ITEM_DESCRIPTOR)` derefs across 24 files with
these calls. Behavior-preserving: same deref, all reads were already const,
and each site keeps its own null-check and field reads. Helpers add a
cgItem null guard (strictly safer).
2026-08-08 15:08:34 -05:00
Brues 2612118ff6 refactor(object): centralize GUID->object resolution in Object::ByGuid
Every caller of the engine object resolver (0x00468460) re-declared the
same __fastcall typedef and split the GUID into lo/hi by hand -- 30 call
sites across 28 files. Add object/Resolve.{h,cpp} with

  void *Object::ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority)

and route every site through it (behavior-identical: same typeMask,
debugName, priority per site; single-uint64 callers pass guid, split-lo/hi
callers reconstruct it).

Also collapse the duplicate offset constant: FUN_CLNT_OBJ_MGR_OBJECT_PTR
was a second name for the same 0x00468460 address as
FUN_OBJECT_RESOLVE_BY_GUID (a single-source-of-truth violation). Remove
it; the ABI-equivalent (u32,void*,u64,int) view it documented is folded
into the surviving constant's comment.
2026-08-08 04:13:30 -05:00
Brues 76e40fec96 refactor: share ASCII case-insensitive compare + DBC reverse token lookup
Add baselib/Ascii.h (header-only Ascii::ToLower / Ascii::EqualCI) and
replace the two hand-rolled case-insensitive compares (frame/Attributes'
EqI, taxi/Map's IEqual + AsciiLower).

Add DBC::ClassIdForToken / RaceIdForToken (token -> ChrClasses/ChrRaces
id, the reverse of ClassToken/RaceToken) backed by a shared
IdForStringField walk using Ascii::EqualCI, and replace player/NameCache's
two near-identical ResolveClassToken / ResolveRaceToken loops.
2026-08-08 02:09:08 -05:00
Brues b72c52d9f7 refactor(identity): share NameForGuid, drop friendlist copies
Add Unit::Identity::NameForGuid(guid) -- a passive NameCache peek that
returns the cached character name -- and replace the two identical local
copies in friendlist/IsFriend and friendlist/IsIgnored. Both files no
longer touch Offsets directly, so drop the now-dead include.
2026-08-08 02:08:59 -05:00
Brues 1447317cd3 refactor(dbc): route class/race/area name lookups through DBC::Names
Add DBC::RaceToken (race client-filename token) and make AreaName skip
the parent Record lookup when resolveToParent is false, then replace the
inline ChrClasses/ChrRaces/AreaTable name+token reads across the codebase
with the shared helpers:

  player/Info, creature/Info, player/LocationInfo, unit/ClassBase,
  unit/RaceBase, map/Areas, taxi/Map, loot/History (drops its verbatim
  ClassTokenForId), classes/Info (drops local Records/LocaleIndex).

All behavior-preserving -- the helpers wrap the same DBC::Record /
LocalizedField / StringField calls with identical id==0 / out-of-range /
empty-string -> nullptr semantics (verified against Lookup.cpp).

Left alone: NameCache's reverse token->id resolvers (no forward helper
fits) and the Record() reads of non-name fields in spell/Mod,
map/Overlays, creature/Info.
2026-08-08 01:53:02 -05:00
Brues f293653837 docs: clarify GetNumWhoResults second return is server-dependent
The second value's meaning varies by server core. Mangos/cmangos cores
send the query match count; Turtle (tortoise-wow) sends the whole
realm's online population whenever more than 49 players are online,
ignoring the filter (verified against tortoise-wow MiscHandler.cpp:
data.put(4, count > 49 ? count : clientcount), count = online players).
Earlier wording claimed it was always the match total, which is wrong
off Turtle -- confirmed by a Twinstar /who priest returning 15/15.
2026-08-08 01:09:27 -05:00
Brues ba1e12fca9 feat(friendlist): C_FriendList.GetWhoInfo / GetNumWhoResults
Add the modern table form of /who results. Vanilla's global
GetWhoInfo returns positional values and no class token; GetWhoInfo
here returns a table with filename (the "MAGE"/"WARRIOR" token, keyed
for RAID_CLASS_COLORS) plus fullName, fullGuildName, level, raceStr,
classStr, and area. GetNumWhoResults returns the shown + total counts.
Reads the engine's /who buffer directly (WHO_RESULT_* globals added to
Offsets.h, verified by decompiling Script_GetWhoInfo / GetNumWhoResults).
No gender field -- vanilla's /who entry has no sex.

Share the class/race/zone display-name resolvers: extract ClassName,
ClassToken, AreaName out of GetFriendInfo into dbc/Names.{h,cpp} (adding
RaceName and an AreaName resolveToParent flag -- friends resolve a
sub-area to its zone, /who uses the area's own name). GetFriendInfo and
GetWhoInfo both use them now.

Document both functions in docs/API.md and README.md.
2026-08-08 01:01:39 -05:00
Brues 867478f410 feat(friendlist): add classFilename to GetFriendInfo
Return the locale-independent class token ("WARRIOR", "MAGE", ...)
alongside the localized className, read from the same ChrClasses.dbc
filename column GetPlayerInfoByGUID's englishClass uses. It is the key
for RAID_CLASS_COLORS and other class tables, so addons can color a
friend's name without matching the localized class name. nil when
unknown, so className and classFilename appear together. Document in
docs/API.md.
2026-08-08 00:40:41 -05:00
Brues caeb598b78 feat(player): resolve UnitNameFromGUID from the friends list
Add a friends-list fallback to UnitNameFromGUID, between the object
manager and the persistent name cache. The friend list keeps name +
GUID for online AND offline friends (SMSG_FRIEND_LIST), so a friend's
GUID now resolves to a name even when the unit is not synced and was
never seen in chat -- the case that previously returned nil.

Only UnitNameFromGUID uses this: it returns name + realm, and the
friend entry has the name. GetPlayerInfoByGUID is left unchanged because
it also returns race and sex, which the friend entry does not carry.

Add FriendList::NameForGuid helper. Document the lookup order in
docs/API.md.
2026-08-08 00:28:34 -05:00
Brues 37ca209210 feat(friendlist): C_FriendList.GetNumOnlineFriends
Add the online-friend count. Vanilla stores no online total, so count
friend entries whose connected byte is set -- the same flag
GetFriendInfo reports. Document in docs/API.md and README.md.
2026-08-08 00:02:15 -05:00
Brues 94c5eaafd2 feat(locale): backport SET_FRIENDNOTE_LABEL / SET_NOTE globals
Add the two friend-note GlobalStrings vanilla 1.12 lacks, per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW), from Blizzard's Classic
GlobalStrings. Lets note-aware UI (e.g. a pfUI "Set Note" entry driven
by C_FriendList.SetFriendNotes) label itself in the client language.
2026-08-07 23:22:41 -05:00
Brues 3f09b3f883 feat(friendlist): C_FriendList.IsIgnored / IsIgnoredByGuid
Add ignore-list membership checks. Vanilla's ignore list is a flat u64
GUID array in the social singleton (social + 0x650, stride 8, max 25,
terminated by the first zero) -- verified by decompiling GetIgnoreName
(FUN_005AE570 reads social + 0x650 + index*8) and GetNumIgnores
(FUN_005AE550 stops at the first zero, caps at 25).

IsIgnoredByGuid parses the GUID string and walks the array.
IsIgnored accepts a name or GUID string (mirroring IsFriend); for a name
it resolves each ignored GUID to its cached name through the NameCache
and compares -- the same GUID->name path GetIgnoreName uses -- so it
matches any ignored player the client has synced this session.

Share the ignore layout + IgnoreGuidByIndex / IsGuidIgnored helpers in
FriendList.h alongside the friend layout. Document both functions in
docs/API.md and README.md.
2026-08-07 22:50:15 -05:00
Brues 9f640de74b fix(addon): use plain info tables in the unit menu (stock 1.12 has no UIDropDownMenu_CreateInfo)
UIDropDownMenu_CreateInfo does not exist in stock 1.12.1 FrameXML, so
building the Focus and Cancel buttons through it errored with "attempt
to call global UIDropDownMenu_CreateInfo (a nil value)". Build the info
tables directly ({}) -- the vanilla idiom UIDropDownMenu_AddButton
expects.
2026-08-07 22:27:44 -05:00
Brues 67b968c36c fix(addon): NPC unit menu + working Cancel on the Focus dropdown
Unify ClassicAPI_ToggleUnitMenu so every menu (players and NPCs) shows
the base menu minus its trailing Cancel, then appends Focus + Cancel.
An NPC resolves to the RAID_TARGET_ICON menu, whose entries
UnitPopup_HideButtons hides entirely unless you are grouped and can
place markers -- so solo the menu was empty and never opened. The
appended Focus + Cancel now always open it, and the NPC's own name is
used as the title.

Give the Cancel button a no-op func: UIDropDownMenuButton_OnClick
early-returns when a button has no func and never hides the list, so the
funcless Cancel did nothing on click.
2026-08-07 21:12:37 -05:00
Brues f833aad769 feat(addon): Set/Clear Focus on the unit right-click menu
Add a contextual Focus / Clear Focus entry to the ClassicAPI unit
dropdown (ClassicAPI_ToggleUnitMenu), shown for real non-self units
(party / raid / target). pfUI unit frames route their right-click
through this dropdown, so it reaches every frame in use.

Built entirely inside our own dropdown: no hooksecurefunc and no
mutation of Blizzard's shared UnitPopupMenus tables, so default frames
and other addons are unaffected. Cancel stays last by showing the base
menu minus its trailing Cancel, then appending Focus and Cancel with our
own click funcs. "Clear Focus" shows when the unit is the current focus,
"Set Focus" otherwise.

Labels use localized SET_FOCUS / CLEAR_FOCUS globals, added per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW) from Blizzard's Classic
GlobalStrings.
2026-08-07 21:05:14 -05:00
Brues b38673f51f docs: document the friend-notes API
Update the GetFriendInfo entry so notes is a live field (with the
per-character persistence note), add SetFriendNotes and
SetFriendNotesByIndex sections plus TOC and README-matrix rows. Example
values are from a verified in-game /dump.
2026-08-07 20:14:07 -05:00
Brues f2161265be Add a native friend-notes system (C_FriendList SetFriendNotes / notes)
Vanilla has no friend notes, so persist them client-side like the equipment manager. New friendlist/Notes.{h,cpp}
stores guid -> note in
WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt --
per-character (matching the friends list's own scope), GUID-keyed (stable
and realm-unique), atomic tmp+rename write.

Expose C_FriendList.SetFriendNotes(name, notes) and
SetFriendNotesByIndex(index, notes) -- empty/nil clears, fires
FRIENDLIST_UPDATE -- and fill the notes field in GetFriendInfo /
GetFriendInfoByIndex from the store.

Character isolation (the equipment-manager trap): EnsureLoaded re-resolves
the path every call and reloads on change; Load clears the map before the
file-open check, so a note-less character still wipes the previous one's
map; and an empty-globals transition drops the cache so a stale path is
never read or written.

FriendInfo's optional strings (notes, className, area) are left nil when
absent instead of "" -- SetFieldString coerces null to "", which is
truthy in Lua and would fool an `if info.notes then` check.

Verified in-game: set / get / clear by name and by index.
2026-08-07 20:14:04 -05:00
Brues eefcbb17f2 docs: document C_FriendList.GetFriendInfo family
Add API.md sections and the README matrix row for GetFriendInfo,
GetFriendInfoByIndex, and GetNumFriends, with the FriendInfo field list
and example values captured from a live /dump (verified, not guessed).
2026-08-07 19:42:16 -05:00
Brues 599a65d80c Add C_FriendList.GetFriendInfo / GetFriendInfoByIndex / GetNumFriends
Return the modern FriendInfo table (name, connected, level, className,
area, guid, afk, dnd, plus vanilla-truthful mobile / referAFriend /
rafLinkType defaults). Fields are read straight from the friend entry,
mirroring the engine's GetFriendInfo (FUN_005AD060): class name via
ChrClasses.dbc, zone via AreaTable.dbc resolved to the parent area, both
locale-applied. Verified in-game against a live friends list -- online,
offline, level-60 and level-1, with AFK and DND toggled.

Factor the friend-entry layout and list accessors into a shared
friendlist/FriendList.h and move IsFriend onto it, so the one struct has
one definition. Add OFF_AREATABLE_PARENT_ID (0x08) for the zone resolve.
2026-08-07 19:41:11 -05:00
Brues 228c5daa0f docs: document IsFriend, GetSpellDispelType, IsNextMeleeSpell, ResetsMeleeSwing
Add API.md sections and README matrix rows for the four functions, with
example returns verified against Spell.dbc. Also correct two stale docs:
- IsRangedAutoAttackSpell now describes the AttributesEx2 autorepeat flag
  (bit 0x20), matching the fixed behavior -- not the old Attributes
  RANGED-bit test that wrongly matched Aimed Shot / Multi-Shot.
- ResetsMeleeSwing mirrors the server's IsMeleeAttackResetSpell: true for
  cast-time spells whose casting resets the swing (Fireball, Frostbolt),
  false for instant specials (Sinister Strike) and for Slam (NOT_RESET
  flag). The first draft had the examples and framing backwards.
2026-08-07 19:22:09 -05:00
Brues cc6953a530 Add C_FriendList.IsFriend(guid)
Backport the friend-list membership check. Friends live inline in the
social singleton (0x00C28168): up to 50 entries, stride 0x20, each with
a GUID at +0x08 and a name pointer at +0x04 (verified from GetNumFriends
/ GetFriendInfo and the count walker FUN_005AE490). IsFriend matches the
input GUID against +0x08 -- the GUID the server sends for online and
offline friends -- with a name-compare fallback (GUID -> NameCache name),
and also accepts a bare character name.

Renamed VAR_WHO_SYSTEM -> VAR_SOCIAL_SYSTEM: 0x00C28168 is the one social
singleton shared by the friend list, ignore list, and /who system, so it
gets one accurate name instead of a who-specific alias plus a duplicate.
2026-08-07 19:21:59 -05:00
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
153 changed files with 5040 additions and 1848 deletions
+80 -16
View File
@@ -4,48 +4,112 @@ 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:
files: build/Release/ClassicAPI.dll
tag_name: ${{ steps.ver.outputs.tag }}
files: |
build/Release/ClassicAPI.dll
build/Release/ClassicAPI.map
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
@@ -7,46 +7,16 @@
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
-- off that trigger.
-- cast, so a registered rule mirrors the server's edit off that trigger.
--
-- A trigger is matched by exact spellID (stable across ranks -- these are the
-- IDs the server binds its spell script to); the affected aura by
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
-- trigger is a whole class of spells rather than a named ability,
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
-- index instead (see Shadow Weaving below).
--
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
-- server-side probability roll on Ferocious Bite. The client can't know the
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
-- register it yourself, e.g. (DRUID = 7):
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
-- removal path, so the reduce rule below is harmless there and correct on
-- Turtle (where Immolate keeps ticking with 3s shaved off).
-- A trigger keyed to a whole class of spells (rather than a named ability) is
-- registered from Lua with RegisterAuraDurationModifierByTrigger, matched by
-- SpellFamilyName + school index (see Shadow Weaving below). Exact-spellID
-- triggers are registered from C++ (Aura::Source::AddDurationMod): Turtle's
-- Conflagrate -> Immolate and Molten Blast -> Flame Shock live in the DLL
-- (src/turtle/DurationMods.cpp), as does Carnage, whose per-combo-point roll no
-- rule can express (src/turtle/Carnage.cpp).
if TURTLE_WOW_VERSION then
local WARLOCK, SHAMAN = 5, 11
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
for i = 1, table.getn(CONFLAGRATE) do
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
end
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
for i = 1, table.getn(MOLTEN_BLAST) do
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
end
end
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-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)
+77 -11
View File
@@ -7,19 +7,46 @@
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
-- PartyMemberFrame show on right-click. Lets a unit frame express its
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
-- hand-rolled OnClick handler.
-- hand-rolled OnClick handler. pfUI's unit frames use exactly this path
-- (api/unitframes.lua -> ClassicAPI_ToggleUnitMenu).
--
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
-- RAID_TARGET_ICON. Vanilla's stock UI never wires a unit to the "RAID" menu
-- (there are no clickable raid unit frames in 1.12), and PARTY carries the
-- options players actually want on a grouped member, so grouped players (party
-- or raid) use PARTY -- matching how pfUI drives its raid menu.
-- RAID_TARGET_ICON (the raid-marker list). Vanilla's stock UI never wires a
-- unit to the "RAID" menu (there are no clickable raid unit frames in 1.12),
-- and PARTY carries the options players actually want on a grouped member, so
-- grouped players (party or raid) use PARTY -- matching how pfUI drives its
-- raid menu.
--
-- Focus / Clear Focus: we add one contextual entry -- "Set Focus" when the
-- unit is not the current focus, "Clear Focus" when it is -- for every real,
-- non-self unit, NPCs included. This is done entirely inside our own dropdown:
-- no hooksecurefunc, and no mutation of Blizzard's shared UnitPopupMenus tables
-- (so default frames and other addons are unaffected). Because
-- UnitPopup_ShowMenu hardwires every button's click to UnitPopup_OnClick (which
-- does not know our keys), we add the Focus button ourselves with our own func.
-- To keep Cancel last, we show the standard menu MINUS its trailing Cancel,
-- then append Focus and Cancel.
--
-- This also fixes NPCs opening no menu at all: an NPC resolves to the
-- RAID_TARGET_ICON menu, whose entries UnitPopup_HideButtons hides entirely
-- unless you are in a group and can place markers -- so solo the menu was
-- empty and never opened. Appending Focus + Cancel ourselves guarantees the
-- menu always opens (markers still show when they are allowed).
--
-- Backed by the DLL's FocusUnit / ClearFocus globals and the `focus` unit
-- token (src/unit/Focus.cpp). Labels come from the localized SET_FOCUS /
-- CLEAR_FOCUS globals (locales/*.lua).
local dropdown;
-- Scratch menu key we own, rebuilt each open as a copy of the resolved base
-- menu with its trailing CANCEL removed. Kept out of Blizzard's tables so the
-- stock menus are never mutated.
local SCRATCH_MENU = "CLASSICAPI_UNITMENU";
local function EnsureDropdown()
if not dropdown then
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
@@ -42,18 +69,57 @@ local function ResolveMenu(unit)
return "RAID_TARGET_ICON";
end
-- Append the contextual Focus / Clear Focus button for `unit`, with our own
-- click func. Skipped for the player themselves (focusing yourself is a no-op).
local function AddFocusButton(unit)
if UnitIsUnit(unit, "player") then
return;
end
local info = {};
info.notCheckable = 1;
if UnitExists("focus") and UnitIsUnit(unit, "focus") then
info.text = CLEAR_FOCUS;
info.func = function() ClearFocus(); end;
else
info.text = SET_FOCUS;
info.func = function() FocusUnit(unit); end;
end
UIDropDownMenu_AddButton(info);
end
local function AddCancelButton()
local info = {};
info.text = CANCEL;
info.notCheckable = 1;
info.func = function() end;
UIDropDownMenu_AddButton(info);
end
function ClassicAPI_ToggleUnitMenu(unit)
if not unit or not UnitExists(unit) then
return;
end
local which = ResolveMenu(unit);
local name;
if which == "RAID_TARGET_ICON" then
name = RAID_TARGET_ICON;
end
local dd = EnsureDropdown();
dd.initialize = function()
UnitPopup_ShowMenu(dd, which, unit, name);
dd.initialize = function(level)
level = level or UIDROPDOWNMENU_MENU_LEVEL or 1;
if level > 1 then
UnitPopup_ShowMenu(dd, which, unit);
return;
end
local base = UnitPopupMenus[which];
local scratch = {};
for _, value in ipairs(base) do
if value ~= "CANCEL" then
table.insert(scratch, value);
end
end
UnitPopupMenus[SCRATCH_MENU] = scratch;
UnitPopup_ShowMenu(dd, SCRATCH_MENU, unit);
AddFocusButton(unit);
AddCancelButton();
end
ToggleDropDownMenu(1, nil, dd, "cursor");
end
+34
View File
@@ -2,7 +2,33 @@ 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.";
CLEAR_FOCUS = "Fokus löschen";
SET_FOCUS = "Fokus setzen";
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 +41,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
SET_NOTE = "Notiz eintragen";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
+34
View File
@@ -1,11 +1,41 @@
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.";
CLEAR_FOCUS = "Clear Focus";
SET_FOCUS = "Set Focus";
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,7 +69,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
+34
View File
@@ -2,7 +2,33 @@ 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.";
CLEAR_FOCUS = "Quitar foco";
SET_FOCUS = "Establecer foco";
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 +41,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
SET_NOTE = "Establecer nota";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
+34
View File
@@ -2,6 +2,31 @@ 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.";
CLEAR_FOCUS = "Effacer la focalisation";
SET_FOCUS = "Définir la focalisation";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
@@ -12,9 +37,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
SET_NOTE = "Écrire une note";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
+34
View File
@@ -2,6 +2,31 @@ 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\".";
CLEAR_FOCUS = "Отменить запоминание цели";
SET_FOCUS = "Запомнить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
@@ -12,9 +37,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
+34
View File
@@ -2,6 +2,31 @@ 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中的一件或多件物品,无法装备。";
CLEAR_FOCUS = "清除焦点";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
@@ -12,9 +37,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
+34
View File
@@ -2,6 +2,31 @@ 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設定中的一個或多個物品未尋獲,因此無法裝備。";
CLEAR_FOCUS = "清除專注目標";
SET_FOCUS = "設定專注目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
@@ -12,9 +37,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,7 +72,11 @@ 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%%";
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
SET_NOTE = "設定註記";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
+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
+43 -28
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})
@@ -134,5 +144,10 @@ set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
set(CMAKE_SHARED_LINKER_FLAGS
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT"
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
# crash dumps back to functions. Published as a build/release artifact so
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
# identical functions, so a few map symbols share an address — harmless.)
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP"
)
+8 -6
View File
@@ -35,8 +35,8 @@ 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` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [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.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
@@ -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` |
@@ -59,8 +59,8 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
@@ -71,7 +71,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -188,6 +188,8 @@ when launching with `-console`), not as Lua functions. See the
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
### Unit tokens
+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")
+604 -163
View File
@@ -131,6 +131,7 @@ build instructions.
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)](#update_mouseover_unit-event-loss-fire-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [`UNIT_SPELLCAST_*` events](#unit_spellcast_-events)
- [Expansion](#expansion)
- [`GetClassicExpansionLevel()`](#getclassicexpansionlevel)
@@ -159,6 +160,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)
@@ -170,6 +172,17 @@ build instructions.
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
- [`C_FriendList.IsWhoQueryPending()`](#c_friendlistiswhoquerypending)
- [`C_FriendList.GetNumWhoResults()`](#c_friendlistgetnumwhoresults)
- [`C_FriendList.GetWhoInfo(index)`](#c_friendlistgetwhoinfoindex)
- [`C_FriendList.IsFriend(guid)`](#c_friendlistisfriendguid)
- [`C_FriendList.IsIgnored(token)`](#c_friendlistisignoredtoken)
- [`C_FriendList.IsIgnoredByGuid(guid)`](#c_friendlistisignoredbyguidguid)
- [`C_FriendList.GetNumFriends()`](#c_friendlistgetnumfriends)
- [`C_FriendList.GetNumOnlineFriends()`](#c_friendlistgetnumonlinefriends)
- [`C_FriendList.GetFriendInfo(name)`](#c_friendlistgetfriendinfoname)
- [`C_FriendList.GetFriendInfoByIndex(index)`](#c_friendlistgetfriendinfobyindexindex)
- [`C_FriendList.SetFriendNotes(name, notes)`](#c_friendlistsetfriendnotesname-notes)
- [`C_FriendList.SetFriendNotesByIndex(index, notes)`](#c_friendlistsetfriendnotesbyindexindex-notes)
- [GameObject](#gameobject)
- [`C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)`](#c_gameobjectinfogetgameobjectinfobyidgameobjectid)
@@ -204,6 +217,8 @@ build instructions.
- [`Enum.ItemClass`](#enumitemclass)
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [`Enum.SpellBookSpellBank`](#enumspellbookspellbank)
- [`Enum.SpellBookItemType`](#enumspellbookitemtype)
- [Glue](#glue)
- [`C_Glue.IsFirstLoadThisSession()`](#c_glueisfirstloadthissession)
@@ -335,6 +350,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)
@@ -416,6 +432,7 @@ build instructions.
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
- [`C_Spell.GetSpellEffectMechanics(spellID)`](#c_spellgetspelleffectmechanicsspellid)
- [`C_Spell.GetSpellDispelType(spellID)`](#c_spellgetspelldispeltypespellid)
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
@@ -436,6 +453,8 @@ build instructions.
- [`C_Spell.IsSpellInRange(spellIdentifier, targetUnit)`](#c_spellisspellinrangespellidentifier-targetunit)
- [`C_Spell.IsAutoAttackSpell(spellID)`](#c_spellisautoattackspellspellid)
- [`C_Spell.IsRangedAutoAttackSpell(spellID)`](#c_spellisrangedautoattackspellspellid)
- [`C_Spell.IsNextMeleeSpell(spellID)`](#c_spellisnextmeleespellspellid)
- [`C_Spell.ResetsMeleeSwing(spellID)`](#c_spellresetsmeleeswingspellid)
- [`IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`](#isharmfulspellspell--ishelpfulspellspell)
- [`C_Spell.IsSpellHarmful(spellID)` / `C_Spell.IsSpellHelpful(spellID)`](#c_spellisspellharmfulspellid--c_spellisspellhelpfulspellid)
- [`GetSpellSchool(spellID)`](#getspellschoolspellid)
@@ -445,12 +464,12 @@ build instructions.
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
- [`UNIT_SPELLCAST_*` events (player)](#unit_spellcast_-events-player)
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
- [`C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`](#c_spellbookgetspellbookiteminfoslotindex-spellbank)
- [`C_SpellBook.GetSpellLevelLearned(spellID)`](#c_spellbookgetspelllevellearnedspellid)
- [`C_SpellBook.GetCurrentLevelSpells([level])`](#c_spellbookgetcurrentlevelspellslevel)
- [`C_SpellBook.GetSkillLineName(skillLineID)`](#c_spellbookgetskilllinenameskilllineid)
@@ -562,7 +581,7 @@ build instructions.
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
- [`C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`](#c_unitaurasregistercombodurationspellid-baseseconds-maxseconds)
- [`C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`](#c_unitaurasregisterauradurationmodifiertriggerspellid-affectedfamily-affectedfamilyflags-affectedicon-op--valueseconds)
- [`C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`](#c_unitaurasregisterauradurationmodifierbytriggertriggerfamily-triggerschool-affectedfamily-affectedfamilyflags-affectedicon-op--valueseconds)
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
@@ -3228,6 +3247,131 @@ The cached "last form" sentinel uses `-1` for "player descriptor not
yet resolvable" so transient resolution failures during early login
don't get misread as leaving a form.
### `UNIT_SPELLCAST_*` events
Backport of the TBC+ cast/channel events to 1.12, for the **local player and
other units**. Ported cast-bar / rotation addons (anything written against
the modern signature) register these instead of vanilla's arg-less
`SPELLCAST_*` events and read `unit, castGUID, spellID` directly. Thirteen
events are provided; six also fire for non-player units:
| Event | Fires when | Units | Args |
|-------|-----------|-------|------|
| `UNIT_SPELLCAST_SENT` | `CMSG_CAST_SPELL` leaves the client (earliest point) | player | `unit, target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | a cast-time spell begins | all | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | a cast-time spell ends (any reason) | all | same |
| `UNIT_SPELLCAST_DELAYED` | pushback extends the cast | player | same |
| `UNIT_SPELLCAST_SUCCEEDED` | the spell goes off (`SMSG_SPELL_GO`) — incl. instants | all | same |
| `UNIT_SPELLCAST_INTERRUPTED` | a started **cast** is interrupted (kick, movement, LoS) — never for channels | all | same |
| `UNIT_SPELLCAST_FAILED` | a cast is rejected before it starts (range, mana, cooldown) with an error shown | player | same |
| `UNIT_SPELLCAST_FAILED_QUIET` | a cast fails with **no** error shown (spammy retry, reticle cancel, …) | player | same |
| `UNIT_SPELLCAST_CHANNEL_START` | a channel begins | all | same |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | pushback shortens a channel | player | same |
| `UNIT_SPELLCAST_CHANNEL_STOP` | a channel ends | all | same |
| `UNIT_SPELLCAST_RETICLE_TARGET` | a ground-target reticle appears (AoE placement — Blizzard, Flare, …) | player | `unit, "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | the reticle is placed or cancelled | player | `unit, "", spellID, spellName, rank` |
`unit` (arg1) is the token of the casting unit — `"player"` for your own
casts, or a unit token (`"target"`, `"focus"`, `"party3"`, `"nameplate2"`,
`"pet"`, `"mouseover"`, …) for another unit. `spellName` / `rank` are
ClassicAPI tail extensions (modern stops at `spellID`); addons reading only
the first three positional args are unaffected.
**Per-token fan-out.** A caster GUID can map to several tokens at once (your
`target` is also `party2` and `nameplate1`). Like retail, the event fires
**once per token** currently pointing at the caster, so a `target`-frame
cast bar, a `party2` frame, and a nameplate cast bar each get their own
event with the same castGUID. Tokens are resolved fresh at fire time (they
shift frame-to-frame). If you run **SuperWoW**, its raw-GUID token (`"0x…"`)
is deliberately filtered out — only standard tokens are fanned out.
**Non-player limits.** Only the six events above fire for other units, and
they're **best-effort** — driven purely by the packets an observer receives:
- `SENT` never fires (only your own outgoing casts are visible).
- `DELAYED` / `CHANNEL_UPDATE` never fire (pushback is sent only to the
caster, so another unit's bar can't stretch/shrink from damage).
- `FAILED` never fires (a pre-cast requirement failure is client-local).
- A remote cast/channel only has timing from the moment its
`SMSG_SPELL_START` was observed; casters who were already casting when
they came into range have no start time.
**Channels never fire `INTERRUPTED`.** Retail emits only `CHANNEL_STOP` when a
channel ends, whether it completed or was cut short (verified against retail),
so ClassicAPI matches that for both the player and other units. `INTERRUPTED`
is a cast-only event.
**Reticle events** fire for ground-targeted (AoE) spells only, always for the
player: `RETICLE_TARGET` when the placement reticle comes up, `RETICLE_CLEAR`
when it's placed or cancelled. There's no cast yet, so the castGUID slot
(arg2) is empty — retail pushes `nil` there, but the engine's event
dispatcher can't emit a `nil` mid-argument-list, so ClassicAPI pushes `""`
instead. `unit` (arg1) and `spellID` (arg3) are exact; arg2 is the only
difference and is inconsequential for a reticle.
**castGUID.** A synthesized string in the modern shape
`Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`. Vanilla
can't know server / instance / zone, so those three fields are `0`; the
load-bearing parts are the `spellID` (field 6, which addons `strsplit("-")`
out) and a unique-per-cast `castUID` (field 7). **Every event of one cast
carries the same castGUID**, so `SENT` → `START`/`CHANNEL_START` →
`SUCCEEDED` → `STOP`/`CHANNEL_STOP` all pair up — including across the caster
and observers (they converge on the same value), and a chained same-spell
recast gets its own castUID. The `type` and `castUID` follow the
[spell-cast-GUID spec](https://warcraft.wiki.gg/wiki/GUID#Cast):
- **Type 3** (real casts — the common case): `castUID` is time-based — the
low 23 bits are the cast's UNIX-epoch second, the higher bits a per-second
counter.
- **Type 2** (`UNIT_SPELLCAST_FAILED` — a local-only cast that never reached
the server): `castUID` is a plain locally-incrementing integer.
**Ordering** matches modern:
- Cast-time spell: `SENT → START → SUCCEEDED → STOP`.
- Channel: `SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP` (CHANNEL_START
before SUCCEEDED, as on retail).
- Instant: `SENT → SUCCEEDED`.
**INTERRUPTED vs FAILED vs FAILED_QUIET** follow modern's split: a spell that
never started (out of range, not enough mana, on cooldown, LoS to a target)
fires `FAILED`, except for a fixed whitelist of "quiet" `SpellCastResult`
codes that fire `FAILED_QUIET` instead — `SPELL_IN_PROGRESS` (casting while
already casting / a spell-queue rejection), `DONT_REPORT` (fake fails, a
cancelled ground reticle), and `CHARMED`. That whitelist mirrors the 3.3.5
client's own unit-spellcast dispatch, mapped to vanilla's `SpellCastResult`
enum. A spell that was *already casting* and gets stopped (an enemy kick,
moving to cancel, breaking LoS mid-cast) fires `INTERRUPTED`. Holding
the cast key while running fires `INTERRUPTED` repeatedly (once per retry),
each reusing the interrupted cast's castGUID — matching retail.
**Channel pushback (player).** Taking damage while channeling shortens the
channel in vanilla; `CHANNEL_UPDATE` fires on each hit and
[`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)'s
`endTimeMs` re-anchors to the server's new remaining time, so cast bars
shrink correctly. (The event carries no time — like retail it's a "re-read
now" trigger; timing is read back from `UnitChannelInfo`.)
Every fire is gated on whether any frame is registered for that event, so
the whole system costs one pointer-compare per state transition when no
addon uses it (no arg synthesis, no DBC lookups, no per-token fan-out).
```lua
local f = CreateFrame("Frame")
for _, e in ipairs({
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
"UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function()
-- vanilla passes event/arg1/... as globals, not function params
if arg1 == "target" then print(event, arg3) end -- arg3 = spellID
end)
```
> **Additive to the vanilla `SPELLCAST_*` events.** The engine's own arg-less
> `SPELLCAST_START` / `SPELLCAST_CHANNEL_UPDATE` / … still fire as before;
> these `UNIT_`-prefixed events are the modern layer on top. The empowered-cast
> events (`UNIT_SPELLCAST_EMPOWER_*`, a Dragonflight addition) are not
> implemented — vanilla has no empowered casts.
## Expansion
Helpers shipped by modern Classic Era / Cata Classic for addons that
@@ -3718,6 +3862,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
@@ -4042,6 +4200,226 @@ contribute to the same pending window. This is acceptable for the
addons that need per-call tracking should manage their own ticket
state on top.
### `C_FriendList.GetNumWhoResults()`
Returns two numbers: how many /who results you can read, and a
server-reported count.
```lua
local shown, serverCount = C_FriendList.GetNumWhoResults()
```
`shown` is the number of entries you can index with
[`GetWhoInfo`](#c_friendlistgetwhoinfoindex). The server caps the list
(50, or 49 on some cores), so `shown` is at most that. Run a `/who` (or
[`SendWhoQueryByName`](#c_friendlistsendwhoquerybynamename)) first to
populate the list.
`serverCount` is the second header value the server sends, and its
meaning depends on the server core:
- Mangos and cmangos derived cores send the number of characters that
matched your query.
- Turtle WoW (tortoise-wow) sends the whole realm's online population
when more than 49 players are online, ignoring your filter. Below
that count it sends the match count.
Do not rely on one meaning across servers. On Turtle it is a live
population counter for any query. On other cores it is a match total.
### `C_FriendList.GetWhoInfo(index)`
Returns a table for the /who result at `index` (1-based), or `nil` when
the index is out of range.
```lua
local info = C_FriendList.GetWhoInfo(1)
-- {
-- fullName = "Nadamage", fullGuildName = "",
-- level = 26, raceStr = "Troll",
-- classStr = "Mage", filename = "MAGE",
-- area = "Hillsbrad Foothills",
-- }
```
Table fields:
- `fullName` — the character name.
- `fullGuildName` — the guild name, or `""` when the player has no
guild.
- `level` — the character level.
- `raceStr` — localized race name, or `nil` when unknown.
- `classStr` — localized class name, or `nil` when unknown.
- `filename` — locale-independent class token (`"WARRIOR"`, `"MAGE"`,
…), or `nil` when unknown. This is the key for `RAID_CLASS_COLORS`.
Vanilla's own `GetWhoInfo` does not provide it, which is the main
reason to use this table form.
- `area` — the zone name.
There is no `gender` field — vanilla's /who result stores no sex.
Vanilla also keeps the global `GetWhoInfo(index)`, which returns
positional values and no class token. This namespaced form returns a
table and adds `filename`.
### `C_FriendList.IsFriend(guid)`
Returns `true` if the player with the given GUID is on your friends
list, `false` if not.
```lua
C_FriendList.IsFriend(UnitGUID("target")) -- true if the target is a friend
C_FriendList.IsFriend("0x00000000000ABCDE")
```
`guid` is a GUID string — the `"0x…"` form that `UnitGUID` returns.
You can also pass a plain character name, because vanilla's friends
list is keyed by name (`AddFriend(name)`).
The check reads the engine's friends list directly. It matches the
input GUID against each friend's stored GUID. The server sends that
GUID for both online and offline friends. As a fallback it also
compares character names, so the answer holds for any friend the
client saw this session.
### `C_FriendList.IsIgnored(token)`
Returns `true` if the player is on your ignore list, `false` if not.
```lua
C_FriendList.IsIgnored("Bob")
C_FriendList.IsIgnored("0x00000000000ABCDE")
```
`token` is a character name or a GUID string. The ignore list stores
GUIDs, not names. A GUID always matches. A name matches only a player
the client has seen this session — the same players `GetIgnoreName` can
name — because the name comes from the client's name cache.
### `C_FriendList.IsIgnoredByGuid(guid)`
Returns `true` if the player with the given GUID is on your ignore
list, `false` if not.
```lua
C_FriendList.IsIgnoredByGuid(UnitGUID("target"))
C_FriendList.IsIgnoredByGuid("0x00000000000ABCDE")
```
`guid` is a GUID string — the `"0x…"` form that `UnitGUID` returns.
This is the exact key the ignore list uses, so it works for every
ignored player.
### `C_FriendList.GetNumFriends()`
Returns the number of players on your friends list. The vanilla global
`GetNumFriends()` returns the same count — this is the modern
namespaced form.
```lua
C_FriendList.GetNumFriends() -- e.g. 12
```
### `C_FriendList.GetNumOnlineFriends()`
Returns how many friends on your list are online. This is a subset of
[`GetNumFriends`](#c_friendlistgetnumfriends), which counts online and
offline friends together.
```lua
C_FriendList.GetNumOnlineFriends() -- e.g. 3
```
Vanilla stores no online count, so the value is the number of friends
whose `connected` flag is set — the same flag
[`GetFriendInfo`](#c_friendlistgetfriendinfoname) reports.
### `C_FriendList.GetFriendInfo(name)`
Returns a `FriendInfo` table for the friend with the given name, or
`nil` if that name is not on your list. The name match is
case-insensitive.
```lua
local info = C_FriendList.GetFriendInfo("Sarahnity")
-- {
-- name = "Sarahnity", connected = true, level = 60,
-- className = "Druid", classFilename = "DRUID", area = "Stratholme",
-- guid = "0x000000003B9CB7BF",
-- afk = false, dnd = false,
-- mobile = false, referAFriend = false, rafLinkType = 0,
-- }
```
Table fields:
- `name` — the friend's character name.
- `connected` — `true` when the friend is online.
- `level` — the friend's level, or `0` when unknown.
- `className` — localized class name, or `nil` when unknown.
- `classFilename` — locale-independent class token (`"WARRIOR"`,
`"MAGE"`, …), or `nil` when unknown. Present whenever `className`
is. This is the key for `RAID_CLASS_COLORS` and other class tables,
so you can color a friend's name without matching the localized
class name.
- `area` — localized zone name, resolved to the parent zone, or `nil`
when unknown.
- `guid` — the friend's GUID string.
- `notes` — your note for this friend, or `nil` if none. Set it with
[`SetFriendNotes`](#c_friendlistsetfriendnotesname-notes).
- `afk` / `dnd` — the friend's status flags.
- `mobile` / `referAFriend` / `rafLinkType` — always `false` / `false`
/ `0`. Vanilla 1.12 has no mobile app, Recruit-A-Friend, or RAF link
data.
Notes are a ClassicAPI addition — vanilla has no note field. ClassicAPI
persists them per character in
`WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt`, local to
this client (not stored on the server, not shared with other machines).
### `C_FriendList.GetFriendInfoByIndex(index)`
The same `FriendInfo` table, addressed by a 1-based list index instead
of a name. Returns `nil` for an index below 1 or above
[`GetNumFriends`](#c_friendlistgetnumfriends).
```lua
for i = 1, C_FriendList.GetNumFriends() do
local info = C_FriendList.GetFriendInfoByIndex(i)
print(info.name, info.connected, info.className, info.area)
end
```
See [`C_FriendList.GetFriendInfo`](#c_friendlistgetfriendinfoname) for
the field list.
### `C_FriendList.SetFriendNotes(name, notes)`
Sets your note for the friend with the given name. Pass an empty string
or `nil` to clear it. There is no return value, and the name must
already be on your friends list.
```lua
C_FriendList.SetFriendNotes("Sarahnity", "raid healer")
C_FriendList.SetFriendNotes("Sarahnity", "") -- clears the note
```
The note is stored client-side and read back through the `notes` field
of [`GetFriendInfo`](#c_friendlistgetfriendinfoname). Setting a note
fires `FRIENDLIST_UPDATE` so the friends UI and note-aware addons
refresh. See the persistence note under `GetFriendInfo`.
### `C_FriendList.SetFriendNotesByIndex(index, notes)`
The same as
[`SetFriendNotes`](#c_friendlistsetfriendnotesname-notes), addressed by
a 1-based list index instead of a name. An out-of-range index does
nothing.
```lua
C_FriendList.SetFriendNotesByIndex(1, "tank")
```
## GameObject
The gameobject analog of [`C_CreatureInfo`](#creature) — reads the
@@ -4958,6 +5336,32 @@ not modern's `ComboPoints` reuse of the same number.
local rage = UnitPower("player", Enum.PowerType.Rage)
```
### `Enum.SpellBookSpellBank`
Selects which spellbook a `C_SpellBook.*` slot query reads. Passed as the
`spellBank` argument to
[`C_SpellBook.GetSpellBookItemInfo`](#c_spellbookgetspellbookiteminfoslotindex-spellbank).
| Value | Field |
|------:|----------|
| `0` | `Player` |
| `1` | `Pet` |
### `Enum.SpellBookItemType`
Categorizes a spellbook slot. Vanilla's spellbook only ever holds real
spells, so `C_SpellBook.GetSpellBookItemInfo` returns `Spell` for a
player-book slot and `PetAction` for a pet-book slot. The other values
exist for signature parity with retail but never occur in 1.12.
| Value | Field | Notes |
|------:|---------------|-------|
| `0` | `None` | Empty slot. Never returned — an empty slot yields `nil` instead. |
| `1` | `Spell` | A player-book spell. |
| `2` | `FutureSpell` | A not-yet-learned trainer spell. Never occurs in 1.12. |
| `3` | `PetAction` | A pet-book spell. |
| `4` | `Flyout` | A flyout group — a later-expansion concept. Never occurs. |
## Glue
Backports of two `C_Glue` session helpers. `C_Glue` is registered on the glue
@@ -8117,6 +8521,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
@@ -9064,10 +9522,16 @@ sex, class) was reverse-engineered from the
### `UnitNameFromGUID(guid)`
Returns `name, realm` for the player identified by `guid`, or `nil`
if no player with that GUID has been encountered yet. Same lookup
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
NameCache, persistent on-disk fallback when enabled) — just narrower
return shape for callers that only need the name.
if no player with that GUID has been encountered yet.
It tries three sources in order:
1. The object manager — any currently-synced unit (a player you can
see, your target, a party or raid member, an NPC in range).
2. Your friends list — it keeps name and GUID for online and offline
friends, so a friend resolves even when not synced and never seen
in chat. Always on.
3. The persistent on-disk name cache, when you have enabled it.
```lua
local name, realm = UnitNameFromGUID(UnitGUID("target"))
@@ -10140,6 +10604,21 @@ Reads `Spell.dbc` directly (`EffectMechanic[3]` at `+0x13C`), so it covers
every spell the client knows — not just the player's spellbook — with no
caching or network round-trip.
### `C_Spell.GetSpellDispelType(spellID)`
Returns the spell's dispel type from `Spell.dbc`: `1` = Magic,
`2` = Curse, `3` = Disease, `4` = Poison. Returns `0` for a spell
that nothing can dispel, and for an unknown spell.
```lua
C_Spell.GetSpellDispelType(118) -- Polymorph → 1 (Magic)
C_Spell.GetSpellDispelType(980) -- Curse of Agony → 2 (Curse)
C_Spell.GetSpellDispelType(133) -- Fireball → 0 (none)
```
Use the number to show what removes an effect, or to filter auras by
dispel type. The values match the `DISPEL_*` set the server uses.
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
@@ -10624,15 +11103,50 @@ C_Spell.IsRangedAutoAttackSpell(5019) -- true (Shoot wand)
C_Spell.IsRangedAutoAttackSpell(6603) -- false (melee)
```
Tests `Spell.dbc.Attributes & 0x02` (the `SPELL_ATTR_AUTO_REPEAT`
flag) — verified empirically against in-game spell data. Auto Shot
(`0x00050012`) and Shoot wand (`0x00000012`) share bit 1; Heroic
Strike (`0x00050014`) and other ranged abilities don't. Naturally
covers any future auto-repeating spell a private server might add.
Tests the auto-repeat flag in `Spell.dbc.AttributesEx2` (bit `0x20`).
Only Auto Shot and Shoot carry it. The on-cast ranged abilities —
Aimed Shot and Multi-Shot — set the general `RANGED` bit but not the
auto-repeat flag, so they return `false`. The flag also covers any
future auto-repeating spell a private server adds. Verified against
`Spell.dbc`.
See [`C_SpellBook.IsRangedAutoAttackSpellBookItem`](#c_spellbookisrangedautoattackspellbookitemslot-booktype)
for the spellbook-slot variant.
### `C_Spell.IsNextMeleeSpell(spellID)`
Returns `true` if the spell is an "on next swing" melee ability.
These abilities queue and land on your next weapon swing instead of
an instant cast (Heroic Strike, Cleave, Maul, Raptor Strike).
```lua
C_Spell.IsNextMeleeSpell(78) -- Heroic Strike → true
C_Spell.IsNextMeleeSpell(6807) -- Maul → true
C_Spell.IsNextMeleeSpell(116) -- Frostbolt → false
```
Tests the two on-next-swing bits in `Spell.dbc.Attributes`
(`0x04` and `0x400`).
### `C_Spell.ResetsMeleeSwing(spellID)`
Returns `true` if casting the spell resets your melee swing timer.
Cast-time spells reset it when you cast them (Fireball, Frostbolt,
Polymorph, wand Shoot). Instant abilities like Sinister Strike do not,
so a rogue can weave them between swings.
```lua
C_Spell.ResetsMeleeSwing(133) -- Fireball → true
C_Spell.ResetsMeleeSwing(1752) -- Sinister Strike → false
C_Spell.ResetsMeleeSwing(1464) -- Slam → false (flagged "no reset")
```
Mirrors the server's `IsMeleeAttackResetSpell`: `true` when the
spell's `InterruptFlags` has the auto-attack bit (`0x08`) and its
`AttributesEx2` does not have the "no reset auto actions" bit
(`0x20000`). Both fields come from `Spell.dbc`. Slam carries that
second bit, so it keeps its swing.
### `C_Item.GetWeaponEnchantInfo()`
Returns the modern 12-tuple matching WotLK+'s extended
@@ -11126,131 +11640,6 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.
### `UNIT_SPELLCAST_*` events
Backport of the TBC+ cast/channel events to 1.12, for the **local player and
other units**. Ported cast-bar / rotation addons (anything written against
the modern signature) register these instead of vanilla's arg-less
`SPELLCAST_*` events and read `unit, castGUID, spellID` directly. Thirteen
events are provided; six also fire for non-player units:
| Event | Fires when | Units | Args |
|-------|-----------|-------|------|
| `UNIT_SPELLCAST_SENT` | `CMSG_CAST_SPELL` leaves the client (earliest point) | player | `unit, target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | a cast-time spell begins | all | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | a cast-time spell ends (any reason) | all | same |
| `UNIT_SPELLCAST_DELAYED` | pushback extends the cast | player | same |
| `UNIT_SPELLCAST_SUCCEEDED` | the spell goes off (`SMSG_SPELL_GO`) — incl. instants | all | same |
| `UNIT_SPELLCAST_INTERRUPTED` | a started **cast** is interrupted (kick, movement, LoS) — never for channels | all | same |
| `UNIT_SPELLCAST_FAILED` | a cast is rejected before it starts (range, mana, cooldown) with an error shown | player | same |
| `UNIT_SPELLCAST_FAILED_QUIET` | a cast fails with **no** error shown (spammy retry, reticle cancel, …) | player | same |
| `UNIT_SPELLCAST_CHANNEL_START` | a channel begins | all | same |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | pushback shortens a channel | player | same |
| `UNIT_SPELLCAST_CHANNEL_STOP` | a channel ends | all | same |
| `UNIT_SPELLCAST_RETICLE_TARGET` | a ground-target reticle appears (AoE placement — Blizzard, Flare, …) | player | `unit, "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | the reticle is placed or cancelled | player | `unit, "", spellID, spellName, rank` |
`unit` (arg1) is the token of the casting unit — `"player"` for your own
casts, or a unit token (`"target"`, `"focus"`, `"party3"`, `"nameplate2"`,
`"pet"`, `"mouseover"`, …) for another unit. `spellName` / `rank` are
ClassicAPI tail extensions (modern stops at `spellID`); addons reading only
the first three positional args are unaffected.
**Per-token fan-out.** A caster GUID can map to several tokens at once (your
`target` is also `party2` and `nameplate1`). Like retail, the event fires
**once per token** currently pointing at the caster, so a `target`-frame
cast bar, a `party2` frame, and a nameplate cast bar each get their own
event with the same castGUID. Tokens are resolved fresh at fire time (they
shift frame-to-frame). If you run **SuperWoW**, its raw-GUID token (`"0x…"`)
is deliberately filtered out — only standard tokens are fanned out.
**Non-player limits.** Only the six events above fire for other units, and
they're **best-effort** — driven purely by the packets an observer receives:
- `SENT` never fires (only your own outgoing casts are visible).
- `DELAYED` / `CHANNEL_UPDATE` never fire (pushback is sent only to the
caster, so another unit's bar can't stretch/shrink from damage).
- `FAILED` never fires (a pre-cast requirement failure is client-local).
- A remote cast/channel only has timing from the moment its
`SMSG_SPELL_START` was observed; casters who were already casting when
they came into range have no start time.
**Channels never fire `INTERRUPTED`.** Retail emits only `CHANNEL_STOP` when a
channel ends, whether it completed or was cut short (verified against retail),
so ClassicAPI matches that for both the player and other units. `INTERRUPTED`
is a cast-only event.
**Reticle events** fire for ground-targeted (AoE) spells only, always for the
player: `RETICLE_TARGET` when the placement reticle comes up, `RETICLE_CLEAR`
when it's placed or cancelled. There's no cast yet, so the castGUID slot
(arg2) is empty — retail pushes `nil` there, but the engine's event
dispatcher can't emit a `nil` mid-argument-list, so ClassicAPI pushes `""`
instead. `unit` (arg1) and `spellID` (arg3) are exact; arg2 is the only
difference and is inconsequential for a reticle.
**castGUID.** A synthesized string in the modern shape
`Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`. Vanilla
can't know server / instance / zone, so those three fields are `0`; the
load-bearing parts are the `spellID` (field 6, which addons `strsplit("-")`
out) and a unique-per-cast `castUID` (field 7). **Every event of one cast
carries the same castGUID**, so `SENT` → `START`/`CHANNEL_START` →
`SUCCEEDED` → `STOP`/`CHANNEL_STOP` all pair up — including across the caster
and observers (they converge on the same value), and a chained same-spell
recast gets its own castUID. The `type` and `castUID` follow the
[spell-cast-GUID spec](https://warcraft.wiki.gg/wiki/GUID#Cast):
- **Type 3** (real casts — the common case): `castUID` is time-based — the
low 23 bits are the cast's UNIX-epoch second, the higher bits a per-second
counter.
- **Type 2** (`UNIT_SPELLCAST_FAILED` — a local-only cast that never reached
the server): `castUID` is a plain locally-incrementing integer.
**Ordering** matches modern:
- Cast-time spell: `SENT → START → SUCCEEDED → STOP`.
- Channel: `SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP` (CHANNEL_START
before SUCCEEDED, as on retail).
- Instant: `SENT → SUCCEEDED`.
**INTERRUPTED vs FAILED vs FAILED_QUIET** follow modern's split: a spell that
never started (out of range, not enough mana, on cooldown, LoS to a target)
fires `FAILED`, except for a fixed whitelist of "quiet" `SpellCastResult`
codes that fire `FAILED_QUIET` instead — `SPELL_IN_PROGRESS` (casting while
already casting / a spell-queue rejection), `DONT_REPORT` (fake fails, a
cancelled ground reticle), and `CHARMED`. That whitelist mirrors the 3.3.5
client's own unit-spellcast dispatch, mapped to vanilla's `SpellCastResult`
enum. A spell that was *already casting* and gets stopped (an enemy kick,
moving to cancel, breaking LoS mid-cast) fires `INTERRUPTED`. Holding
the cast key while running fires `INTERRUPTED` repeatedly (once per retry),
each reusing the interrupted cast's castGUID — matching retail.
**Channel pushback (player).** Taking damage while channeling shortens the
channel in vanilla; `CHANNEL_UPDATE` fires on each hit and
[`C_Spell.UnitChannelInfo`](#c_spellunitchannelinfounit--c_spellchannelinfo)'s
`endTimeMs` re-anchors to the server's new remaining time, so cast bars
shrink correctly. (The event carries no time — like retail it's a "re-read
now" trigger; timing is read back from `UnitChannelInfo`.)
Every fire is gated on whether any frame is registered for that event, so
the whole system costs one pointer-compare per state transition when no
addon uses it (no arg synthesis, no DBC lookups, no per-token fan-out).
```lua
local f = CreateFrame("Frame")
for _, e in ipairs({
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
"UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function()
-- vanilla passes event/arg1/... as globals, not function params
if arg1 == "target" then print(event, arg3) end -- arg3 = spellID
end)
```
> **Additive to the vanilla `SPELLCAST_*` events.** The engine's own arg-less
> `SPELLCAST_START` / `SPELLCAST_CHANNEL_UPDATE` / … still fire as before;
> these `UNIT_`-prefixed events are the modern layer on top. The empowered-cast
> events (`UNIT_SPELLCAST_EMPOWER_*`, a Dragonflight addition) are not
> implemented — vanilla has no empowered casts.
### `C_Spell.GetSpellLevelInfo(spellID)`
Returns the raw `Spell.dbc` level fields for a spell:
@@ -11337,6 +11726,40 @@ end
Equivalent to the legacy function of the same name introduced in 3.0
(later renamed to `FindSpellBookSlotBySpellID` in 5.x).
### `C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`
Returns a table describing the spell in a spellbook slot. `slotIndex` is
1-based across the whole book. `spellBank` picks the book —
[`Enum.SpellBookSpellBank.Player`](#enumspellbookspellbank) (`0`) or
`Enum.SpellBookSpellBank.Pet` (`1`).
```lua
local info = C_SpellBook.GetSpellBookItemInfo(1, Enum.SpellBookSpellBank.Player)
-- info.name, info.spellID, info.itemType, info.isPassive, info.iconID, ...
```
Table fields:
| Field | Type | Notes |
|-------|------|-------|
| `itemType` | [`Enum.SpellBookItemType`](#enumspellbookitemtype) | `Spell` (1) for the player book, `PetAction` (3) for the pet book. |
| `actionID` | number | The spellID. Equals `spellID` — vanilla has no spell-override system. |
| `spellID` | number | The spellID in the slot. |
| `name` | string | The localized spell name. |
| `subName` | string | The rank text (e.g. `"Rank 3"`), or `""` when the spell has no rank. |
| `iconID` | string | The icon **path** — feed it straight to `texture:SetTexture(...)`. |
| `isPassive` | boolean | `true` for a passive spell. |
| `isOffSpec` | boolean | Always `false`. |
Returns `nil` for an empty or out-of-range slot, matching the modern API.
> **Deviations from retail, forced by 1.12's data.** `iconID` is a
> texture path, not a `fileID` (vanilla has no fileID system) — the same
> deviation as [`C_Spell.GetSpellInfo`](#c_spellgetspellinfospellid).
> `skillLineIndex` is not returned (`nil`); spellbook tabs aren't
> SkillLines. `isOffSpec` is always `false` — vanilla has no
> specializations.
### `C_SpellBook.GetSpellLevelLearned(spellID)`
Returns the level at which a spell becomes available — the
@@ -13645,6 +14068,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`
@@ -13724,7 +14172,7 @@ is the escape hatch for *other* custom servers with the same class of
gap — call it from your own config or addon; a registered override
beats both the DBC and the built-in values.
### `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`
### `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`
ClassicAPI extension for **server-side DoT-duration changes on another
unit that the client is never told about**. Returns `true` on success.
@@ -13743,8 +14191,9 @@ trigger lands:
| Arg | Meaning |
|-----|---------|
| `triggerSpellID` | The spell whose cast triggers the change (match one rank per call — use the exact IDs the server binds). |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `7` druid). |
| `triggerFamily` | `SpellFamilyName` of the triggering cast (e.g. `6` priest). |
| `triggerSchool` | School index the trigger must be (`0` physical … `2` fire, `5` shadow, `6` arcane), or `< 0` for any. Matching by family + school covers a whole class of spells at once — every rank, plus server-added ones — rather than a named ability. |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `6` priest). |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
@@ -13754,34 +14203,26 @@ The rule only fires for the aura **cast by the same unit** as the trigger
(these mechanics act on the caster's own DoT), and misses are excluded for
free (a missed trigger isn't in the packet's hit list).
**`C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`**
is the same, but matches the *trigger* by `SpellFamilyName` + school index
(`0` physical … `2` fire, `5` shadow, `6` arcane; `< 0` = any) instead of an
exact spellID — one rule covers a whole class of spells (every rank, plus
server-added ones). Use it when the trigger is a category rather than a named
ability.
Rules keyed to an **exact trigger spellID** (rather than a family + school
category) are registered inside the DLL instead. `!!!ClassicAPI`'s built-in
Turtle mods — Conflagrate shaving 3s off the caster's Immolate, Molten Blast
refreshing the caster's Flame Shock — live in `src/turtle/DurationMods.cpp`,
and Carnage's roll-gated Rip/Rake refresh in `src/turtle/Carnage.cpp`.
`!!!ClassicAPI` ships a default Turtle ruleset
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua)):
Conflagrate shaves 3s off the caster's Immolate, Molten Blast refreshes the
caster's Flame Shock, and — for a 5/5 Shadow Weaving priest (talent `15334`,
where the proc is 100%) — any shadow-school priest cast refreshes the target's
Shadow Vulnerability (`15258`). Deliberately excluded: probabilistic effects
(Carnage's roll-gated Rip/Rake refresh, and Shadow Weaving below 5/5) — the
client can't see the server's roll, so inferring them would show wrong timers.
Two accepted best-effort caveats on the Shadow Weaving rule: DoT *ticks*
(SW:P / Devouring Plague) also refresh it server-side but emit no cast packet,
so a pure DoT-only phase under-counts slightly; and a full-consume Conflagrate
variant on other servers *removes* Immolate, which ClassicAPI already evicts
via the normal removal path (so the reduce rule is harmless there).
The one rule registered from Lua
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua))
is Shadow Weaving: for a 5/5 priest (talent `15334`, where the proc is 100%),
any shadow-school priest cast refreshes the target's Shadow Vulnerability
(`15258`). Below 5/5 it's roll-gated and deliberately not inferred — the client
can't see the server's roll, so inferring it would show wrong timers. One
accepted best-effort caveat: DoT *ticks* (SW:P / Devouring Plague) also refresh
it server-side but emit no cast packet, so a pure DoT-only phase under-counts
slightly.
```lua
-- Flags are hex; Lua 5.0 has no 0x literals, so use tonumber(hex, 16).
-- Conflagrate (Rank 4) shaves 3s off the caster's Immolate (warlock, flag 0x4):
C_UnitAuras.RegisterAuraDurationModifier(18932, 5, tonumber("4", 16), 0, "reduce", 3)
-- Molten Blast refreshes the caster's Flame Shock (shaman, flag 0x10000000):
C_UnitAuras.RegisterAuraDurationModifier(36916, 11, tonumber("10000000", 16), 0, "refresh")
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability (15258):
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability
-- (affected priest 6, flag 0x4000000, icon 9):
C_UnitAuras.RegisterAuraDurationModifierByTrigger(6, 5, 6, tonumber("4000000", 16), 9, "refresh")
```
+181 -27
View File
@@ -198,10 +198,23 @@ enum Offsets {
// Returns an int the caller (Script_GameTooltip_SetUnit) tests, so the
// co-hook must forward the original's return value.
// - GameObject (0x0052aa20, __thiscall(self, guid*), RET 4) — the GO
// hover populator; resolves the GO (OBJ_TYPE_GAMEOBJECT) and writes the
// hover populator; resolves the GO (TYPEMASK_GAMEOBJECT) and writes the
// 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
@@ -1056,6 +1069,10 @@ enum Offsets {
// derive `dispelName` from `Spell.dbc[+0x10]` (see
// `OFF_SPELL_DISPEL_TYPE` below), not from the flags nibble.
OFF_UNIT_FIELD_AURA = 0xA4, // u32 spell ID per slot, 48 slots total
// UNIT_AURA event id for FUN_UNIT_EVENT_BROADCAST. The per-token unit
// events use field-index == event-id; the aura field is index 0x29
// (OFF_UNIT_FIELD_AURA 0xA4 >> 2), per Unit::TokenObserver's watched set.
UNIT_EVENT_UNIT_AURA = 0x29,
OFF_UNIT_FIELD_AURAFLAGS = 0x164, // 4 bits per aura, 2 per byte, covers all 48
OFF_UNIT_FIELD_AURALEVELS = 0x17C, // u8 caster level per aura, 48 bytes
OFF_UNIT_FIELD_AURAAPPLICATIONS = 0x1AC, // u8 (stacks-1) per aura, display value = byte+1
@@ -1498,6 +1515,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 +1535,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
@@ -1864,7 +1899,7 @@ enum Offsets {
// gated on the bank-open globals). Returns the CGContainer GUID of the bag
// equipped in that slot, or 0 if none. This is the internal PackBagSlot
// uses to resolve bags 1..4: get the bag GUID here, resolve the container
// via FUN_OBJECT_RESOLVE_BY_GUID(OBJ_TYPE_CONTAINER, guid), then call the
// via FUN_OBJECT_RESOLVE_BY_GUID(TYPEMASK_CONTAINER, guid), then call the
// container's vtable[+OFF_CONTAINER_GET_INVENTORY] to get the inventory
// object GetItemBySlot indexes. Lets us enumerate bag contents in pure C++
// without PackBagSlot's Lua-stack coupling. (Reads GUID arrays at
@@ -1920,16 +1955,14 @@ enum Offsets {
//
// Type values: 2 = item (returns CGItem*), 4 = container/bag
// (returns CGContainer*). Engine passes `"ItemMgr"` as debugName
// and `0x172` as priority for both call sites we've decoded.
// and `0x172` as priority for both call sites we've decoded. Some engine
// sites type it as `(u32 typeMask, void *unused, u64 guid, int)` —
// ABI-equivalent (the u64 occupies the same two stack dwords as
// guidLo/guidHi). All our callers go through `Object::ByGuid`
// (object/Resolve.h), which wraps this single address.
FUN_OBJECT_RESOLVE_BY_GUID = 0x00468460,
// The type arg is a bitmask of object-type bits, not an enum
// index — `1<<1` for items, `1<<2` for containers, `1<<3` for
// units, matching what `FUN_00529FE0` passes for SetUnit (`ECX=8`).
OBJ_TYPE_ITEM = 2,
OBJ_TYPE_CONTAINER = 4,
OBJ_TYPE_UNIT = 8,
OBJ_TYPE_PLAYER = 0x10, // what the engine's bag observer FUN_004F8DB0 passes
OBJ_TYPE_GAMEOBJECT = 0x20, // 1<<5; passed by FUN_0052AA20 (hover-tooltip populator).
// The type arg is a bitmask of object-type bits (see the TYPEMASK_*
// block below), not an enum index.
// `CGObject::GetName` — returns the display-name `const char *` for
// a resolved CGObject (CGUnit / CGPlayer / CGCreature). Internally
@@ -2641,6 +2674,10 @@ enum Offsets {
VAR_AREATABLE_RECORDS = 0x00C0E048,
VAR_AREATABLE_COUNT = 0x00C0E04C,
OFF_AREATABLE_NAMES = 0x2C,
// Parent AreaTable id @ +0x08 — a sub-area's enclosing zone (e.g. Goldshire
// → Elwynn Forest). GetFriendInfo resolves it to name the zone, not the
// sub-area. 0 for a top-level zone.
OFF_AREATABLE_PARENT_ID = 0x08,
// AreaBit @ +0x0C: index into the player's explored-areas bitfield
// (OFF_PLAYER_EXPLORED_BITS). Gate @ +0x28: when < 0 the area needs no
// exploration (always counts explored); when >= 0 the AreaBit check
@@ -2811,15 +2848,11 @@ enum Offsets {
// each callback invocation, guidLo/guidHi pushed as 8 stack bytes.
FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS = 0x00468380,
// GUID → CGObject resolver. __fastcall with this signature:
// CGObject *(__fastcall *)(uint32_t typeMask, void *unused,
// uint64_t guid, int unused2);
// typeMask filters to specific object types; returns NULL if the
// GUID isn't loaded or its type doesn't match the mask.
FUN_CLNT_OBJ_MGR_OBJECT_PTR = 0x00468460,
// Type-mask flags accepted by FUN_CLNT_OBJ_MGR_OBJECT_PTR. Single-bit
// flags can be OR'd together.
// Type-mask flags accepted by FUN_OBJECT_RESOLVE_BY_GUID (and the SetUnit
// path FUN_00529FE0, which passes ECX=8 for UNIT). A bitmask of
// object-type bits, not an enum index — single-bit flags OR together.
// PLAYER (0x10) is what the bag observer FUN_004F8DB0 passes; GAMEOBJECT
// (0x20) what the hover-tooltip populator FUN_0052AA20 passes.
TYPEMASK_OBJECT = 0x01,
TYPEMASK_ITEM = 0x02,
TYPEMASK_CONTAINER = 0x04,
@@ -3064,11 +3097,16 @@ enum Offsets {
VAR_FACTION_COLLAPSED_BITMASK = 0x0084A0A4,
MAX_FACTION_HEADERS = 32,
// Who-query (the /who system).
// The social singleton (0x00C28168) — one object shared by the friend
// list, ignore list, and /who system. Layout: friend entries inline from
// offset 0 (stride 0x20, up to 50; each is {connected@+0x00, name char*
// @+0x04, GUID u64 @+0x08, level@+0x10, area@+0x14, class@+0x18}), the
// 25-entry ignore-GUID table at +0x650, and the who-query state. Read by
// GetNumFriends/GetFriendInfo, GetNumIgnores, and Script_SendWho.
//
// `Script_SendWho` (0x005AD3B0) is a 32-byte wrapper:
// - validate arg1 is a string via lua_isstring
// - load WhoSystem singleton from `[VAR_WHO_SYSTEM]`
// - load the singleton from `[VAR_SOCIAL_SYSTEM]`
// - if non-NULL, lua_tostring(L, 1) for the query string
// - tail-call `FUN_WHO_SYSTEM_SEND_QUERY` with
// `__thiscall(this = WhoSystem, queryStr)`
@@ -3089,9 +3127,13 @@ enum Offsets {
// Server-side cooldown for CMSG_WHO is ~5 seconds — a faster
// client gets silent-dropped, so any client-side gating just
// matches that.
VAR_WHO_SYSTEM = 0x00C28168,
VAR_SOCIAL_SYSTEM = 0x00C28168,
VAR_WHO_TO_UI_FLAG = 0x00C2A12C,
FUN_WHO_SYSTEM_SEND_QUERY = 0x005AEBB0,
// Friend count: `__fastcall(socialSingleton) -> uint` — walks the inline
// entries and returns the number of populated friend slots. Backs
// GetNumFriends and C_FriendList.IsFriend.
FUN_FRIEND_LIST_COUNT = 0x005AE490,
// SMSG_WHO opcode handler — opcode 0x63 (99) per the registration
// in `FUN_005adc50`: `FUN_005ab650(99, FUN_005adf60, 0)`. Reads
@@ -3105,6 +3147,25 @@ enum Offsets {
// user actually wanted both.
FUN_SMSG_WHO_RESPONSE = 0x005ADF60,
// /who result list — the buffer `Script_GetWhoInfo` (0x005AD6E0) and
// `Script_GetNumWhoResults` (0x005AD690) read. An array of fixed-size
// entries at `VAR_WHO_RESULTS`, stride `WHO_RESULT_STRIDE`; the field
// offsets below are all relative to an entry. `name`/`guildName` are
// inline C strings; `race`/`class` index ChrRaces/ChrClasses.dbc and
// `zone` indexes AreaTable.dbc (own name, not parent-resolved).
// `VAR_WHO_RESULT_COUNT` is the displayed count (the first of the two
// values GetNumWhoResults returns; the second, total matches, is at +4).
// Verified by decompiling both accessors.
VAR_WHO_RESULTS = 0x00C281B8,
VAR_WHO_RESULT_COUNT = 0x00C2A120,
WHO_RESULT_STRIDE = 0xA0,
OFF_WHO_NAME = 0x00, // char[] inline
OFF_WHO_GUILD = 0x30, // char[] inline
OFF_WHO_LEVEL = 0x90, // i32
OFF_WHO_RACE = 0x94, // i32 → ChrRaces.dbc
OFF_WHO_CLASS = 0x98, // i32 → ChrClasses.dbc
OFF_WHO_ZONE = 0x9C, // i32 → AreaTable.dbc
// Per-faction current standing — `__fastcall(ecx = factionID) → int`.
// Returns `base + delta` where the two values are stored at
// `+0x08` / `+0x0C` of the player's rep slot (slot stride 0x10,
@@ -3407,6 +3468,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
@@ -4401,6 +4510,14 @@ enum Offsets {
// No `%b` for boolean — pass `%d` with 0/1; the engine has no native
// bool concept here. No bounds check on eventID; pass valid indices.
FUN_FIRE_EVENT = 0x00703F50,
// Per-token unit-event broadcast. Fires unit event `eventCode` for every
// unit token currently mapping to `*guid` (target / party / raid /
// nameplateN / mouseover / focus), via the engine's own GUID->token reverse
// map -- the same path the descriptor-field observers drive on a real field
// change. `void __fastcall(uint64_t *guid, uint32_t eventCode)`; verified
// against nampower's SendUnitSignal (0x00515e50). `eventCode` is the unit
// field index, which doubles as the event id (see UNIT_EVENT_UNIT_AURA).
FUN_UNIT_EVENT_BROADCAST = 0x00515E50,
// Event name strings live in `.data` (mostly clustered around
// 0x00851000..0x00855000); event-name string pointers also reach into
// `.rdata` (starts at 0x007FF000) for some entries. Bound the dereference
@@ -5649,6 +5766,7 @@ enum Offsets {
// ENCHANT_ITEM / _TEMPORARY (enchants), and LEARN_SPELL (Turtle's custom
// recipes, e.g. "Steel Plate Boots" = effect 36).
OFF_SPELL_RECORD_EFFECT = 0xF4, // int32[3]
SPELL_EFFECT_SCHOOL_DAMAGE = 2, // direct damage (verified vs server SpellDefines.h)
SPELL_EFFECT_CREATE_ITEM = 24,
SPELL_EFFECT_LEARN_SPELL = 36,
SPELL_EFFECT_ENCHANT_ITEM = 53,
@@ -5680,14 +5798,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 +5859,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);
+5 -15
View File
@@ -55,8 +55,10 @@
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include "tick/WorldTick.h"
@@ -97,27 +99,15 @@ Job g_job{};
// --- small CGItem / cache readers ---------------------------------------
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int CGItemCount(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *desc = Item::ObjectFields(item);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *inst = Item::InstanceBlock(item);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
@@ -188,7 +178,7 @@ bool BagIsGeneral(int bag) {
const int id = Item::ID::FromCGItem(bagItem);
if (id == 0)
return false;
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
const uint8_t *rec = Item::PeekRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
+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;
}
+36 -12
View File
@@ -57,28 +57,52 @@ uint8_t CurrentComboPoints() {
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
}
// One cast in flight at a time is the practical case (a second finisher
// can't be cast until the server consumes the first one's combo points),
// so a single slot with a spellID match and a short TTL suffices.
// Keyed by spellID, because a finisher's own SMSG_SPELL_GO is not reliably
// the next one to arrive after its cast. A talent that procs an aura on the
// finisher (Turtle's rogue Taste for Blood casts spell 52529 on the player)
// lands its SPELL_GO first, and every player-cast aura spell reaches
// TryComboScaledMs. A single slot -- or a lookup that cleared the slot on a
// spellID mismatch -- therefore lost the snapshot to the proc, and the
// finisher fell back to its 0-CP base duration. Only the matching entry is
// ever consumed; a miss leaves every other capture intact.
struct Capture {
uint32_t spellId;
uint8_t comboPoints;
uint32_t capturedMs;
};
Capture g_capture{0, 0, 0};
constexpr int kCaptureCount = 8;
Capture g_captures[kCaptureCount];
int g_captureCursor = 0;
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
// stale (the cast failed / was rejected) and must not scale a later cast.
constexpr uint32_t kCaptureTtlMs = 2000;
void RememberCapture(uint32_t spellId, uint8_t comboPoints, uint32_t nowMs) {
for (auto &c : g_captures) {
if (c.spellId == spellId) { // re-cast before the first resolved
c.comboPoints = comboPoints;
c.capturedMs = nowMs;
return;
}
}
g_captures[g_captureCursor] = {spellId, comboPoints, nowMs};
g_captureCursor = (g_captureCursor + 1) % kCaptureCount;
}
int ConsumeCapturedCP(uint32_t spellId) {
const Capture c = g_capture;
g_capture = {0, 0, 0};
if (c.spellId != spellId || c.comboPoints == 0)
return 0;
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
return 0;
return c.comboPoints;
for (auto &c : g_captures) {
if (c.spellId != spellId)
continue;
const Capture got = c;
c = {0, 0, 0};
if (got.comboPoints == 0)
return 0;
if (NowMs() - got.capturedMs >= kCaptureTtlMs)
return 0;
return got.comboPoints;
}
return 0;
}
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
@@ -94,7 +118,7 @@ void OnSend(uint32_t opcode, Net::CDataStore *packet) {
return;
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
RememberCapture(spellId, CurrentComboPoints(), NowMs());
}
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
+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
+214 -70
View File
@@ -21,6 +21,7 @@
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "object/Resolve.h"
#include "player/StatSignal.h"
#include "spell/CastEvents.h"
#include "spell/Lookup.h"
@@ -130,13 +131,64 @@ 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;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
const auto *unit = static_cast<const uint8_t *>(
Object::ByGuid(kUnitOrPlayerMask, guid, nullptr, 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 +201,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 +221,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 ---------------------------
@@ -245,15 +325,18 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules come from Lua
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
// lives in the addon, not the DLL. Turtle examples:
// server's edit on the cached entry. Rules are registered two ways: the
// family/school-matched form from Lua (`RegisterAuraDurationModifierByTrigger`,
// e.g. Shadow Weaving), and the exact-spellID form from C++ (`AddDurationMod`),
// which src/turtle uses for the server's built-in mods. Turtle examples
// (registered in src/turtle/DurationMods.cpp):
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched by exact spellID (from the server's script binding,
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
// A trigger is matched either by exact spellID (from the server's script
// binding, stable across ranks) or by SpellFamilyName + school; the affected
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
// removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
// deliberately NOT shipped as defaults — the client can't know the server's
@@ -275,23 +358,24 @@ constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
if (rec == nullptr)
// The affected-aura selector alone, shared with the Lua-facing by-family
// refresh so the two cannot disagree about what they match.
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
uint32_t icon) {
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
return false;
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if ((flags & m.affectedMask) == 0)
return false;
if (m.affectedIcon != 0 &&
if (icon != 0 &&
*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != icon)
return false;
return true;
}
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
return AffectedMatchesRaw(rec, m.affectedFamily, m.affectedMask,
m.affectedIcon);
}
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
// by the cast spell's SpellFamilyName + school index — one rule then covers
// every rank / server-added spell of a class's school (e.g. any priest
@@ -312,26 +396,62 @@ bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
return true;
}
// A cache duration edit has no engine packet behind it, so nothing re-fires
// UNIT_AURA and event-driven consumers (aura-bar addons that read the duration
// once and count down locally) never see the change. Re-broadcast UNIT_AURA for
// the affected unit so they re-read. Deferred to the world tick, not fired
// inline: the callers run inside the SMSG_SPELL_GO subscriber, and firing a
// unit event there would run addon Lua mid-packet (the re-entrancy class this
// codebase keeps getting bitten by). The tick flush is the context the engine's
// own UNIT_AURA fires are safe in. Repeats within a frame are coalesced.
constexpr int kPendingSignalMax = 32;
uint64_t g_pendingSignals[kPendingSignalMax];
int g_pendingSignalCount = 0;
void SignalAuraChanged(uint64_t guid) {
if (guid == 0)
return;
for (int i = 0; i < g_pendingSignalCount; ++i)
if (g_pendingSignals[i] == guid)
return;
if (g_pendingSignalCount < kPendingSignalMax)
g_pendingSignals[g_pendingSignalCount++] = guid;
}
void FlushAuraSignals() {
using Broadcast_t = void(__fastcall *)(uint64_t *guid, uint32_t eventCode);
auto broadcast = reinterpret_cast<Broadcast_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_EVENT_BROADCAST));
for (int i = 0; i < g_pendingSignalCount; ++i)
broadcast(&g_pendingSignals[i], Offsets::UNIT_EVENT_UNIT_AURA);
g_pendingSignalCount = 0;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
case MOD_REFRESH:
if (e.durationMs > 0)
if (e.durationMs > 0) {
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
SignalAuraChanged(e.targetGuid);
}
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
SignalAuraChanged(e.targetGuid);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
else
SignalAuraChanged(e.targetGuid);
} else {
e.used = false; // shaved to/past now → server removes it
}
}
break;
case MOD_REMOVE:
e.used = false;
e.used = false; // removal — OnAuraRemoved / descriptor path fires UNIT_AURA
break;
}
}
@@ -414,40 +534,6 @@ int OpFromString(const char *s) {
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
// (trigger, family, flags, icon) tuple replaces its op/value.
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsString(L, 5)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
"valueSeconds])");
return 0;
}
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
const int op = OpFromString(Game::Lua::ToString(L, 5));
const int32_t valueMs =
Game::Lua::IsNumber(L, 6)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
: 0;
if (op < 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, family, mask,
icon, op, valueMs));
return 1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
@@ -485,8 +571,6 @@ int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
&Script_RegisterAuraDurationModifier);
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
@@ -534,8 +618,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.
@@ -543,6 +637,9 @@ void OnWorldTick() {
if (s.used && now - s.touchMs > kGroupSnapshotTtlMs)
s.used = false;
}
// Re-fire UNIT_AURA for units whose cached duration a mod/refresh changed
// this frame (see SignalAuraChanged).
FlushAuraSignals();
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
@@ -772,12 +869,59 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Exact-spellID trigger (triggerFamily 0, triggerSchool -1).
return RegisterDurationMod(triggerSpellId, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, affectedFamily, affectedMask,
affectedIcon, op, valueMs);
}
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid) {
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
return 0;
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
// Caller's own aura only, scoped as ApplyDurationModifiers scopes a
// rule; a caster-less entry is claimed rather than skipped, same as
// there.
if (e.casterGuid != 0 && e.casterGuid != casterGuid)
continue;
if (!AffectedMatchesRaw(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), family,
mask, icon))
continue;
if (e.durationMs == 0)
continue; // never invent a duration
if (e.casterGuid == 0)
e.casterGuid = casterGuid;
// The applied duration, not a recomputed one (the server's
// RefreshHolder): a Rip cast at 3 combo points returns to its own
// duration, not a five-point one.
e.expirationMs = now + e.durationMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
return e.spellId;
}
return 0;
}
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) {
+28
View File
@@ -42,6 +42,34 @@ namespace Aura::Source {
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
// if nothing matched. For a mechanic whose duration edit reaches the client
// too late to be attributed to a cast packet, so no rule can express it.
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
// Op codes for AddDurationMod (mirror the Lua op strings).
enum DurationModOp {
DURATION_MOD_REFRESH = 0,
DURATION_MOD_REDUCE = 1,
DURATION_MOD_SET = 2,
DURATION_MOD_REMOVE = 3,
};
// Register a server duration-modifier rule from C++. The trigger is matched by
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
// modules for the server's built-in mods (the family/school-matched variant is
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
// Returns false on bad input or a full table.
bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
+38
View File
@@ -0,0 +1,38 @@
// 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
// Tiny ASCII string helpers. Header-only — no locale, no allocation, safe from
// any context (hook callbacks, DBC walks). Use these instead of hand-rolling
// yet another case-insensitive compare.
namespace Ascii {
// Fold a single ASCII char to lower case (non-letters unchanged).
inline char ToLower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
// Case-insensitive full-string equality over ASCII. Two null pointers compare
// equal; a null vs non-null does not.
inline bool EqualCI(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
for (; *a && *b; ++a, ++b)
if (ToLower(*a) != ToLower(*b))
return false;
return *a == *b;
}
} // namespace Ascii
+8 -30
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Names.h"
#include <cstdint>
@@ -22,21 +23,11 @@ namespace Classes::Info {
namespace {
const uint8_t *const *Records() {
return *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
}
int Count() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
}
int LocaleIndex() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// `FillLocalizedClassList(table [, isFemale]) → table` — fills the
// passed-in table with `[classToken] = localizedName` pairs for every
// class in `ChrClasses.dbc`, then returns the same table for chaining.
@@ -58,15 +49,7 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
return 0;
}
auto *records = Records();
if (records == nullptr) {
// DBC not loaded yet — return the table unchanged. Matches
// modern behavior of "fill what's available, don't crash".
Game::Lua::SetTop(L, 1);
return 1;
}
const int count = Count();
const int locale = LocaleIndex();
// Drop the optional `isFemale` arg (and any further junk) so the
// table sits alone at idx 1. Each loop iteration pushes key+value
@@ -74,19 +57,14 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
// [table] before the next iteration.
Game::Lua::SetTop(L, 1);
for (int i = 1; i <= count; i++) {
const uint8_t *record = records[i];
if (record == nullptr)
// DBC::* are null-safe (empty result if ChrClasses.dbc isn't loaded)
// and skip empty strings, so a sparse/absent class id just falls
// through — same "fill what's available, don't crash" behavior.
const char *token = DBC::ClassToken(static_cast<uint32_t>(i));
if (token == nullptr)
continue;
const char *token = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_CHRCLASSES_FILENAME);
if (token == nullptr || *token == '\0')
continue;
const char *const *names = reinterpret_cast<const char *const *>(
record + Offsets::OFF_CHRCLASSES_NAMES);
const char *name = names[locale];
if (name == nullptr || *name == '\0')
const char *name = DBC::ClassName(static_cast<uint32_t>(i));
if (name == nullptr)
continue;
Game::Lua::PushString(L, token);
+2 -16
View File
@@ -16,6 +16,7 @@
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -23,21 +24,6 @@ namespace Container::FreeSlots {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// Same `Peek the item cache without firing a query` shape as
// `Item::Info::FetchItemRecord`. We don't share that helper because it's
// file-static there; copying the four-line lookup is simpler than
// promoting it to a header for one extra caller.
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Resolves the bag's metadata: total slot count + BagFamily bitfield.
//
// - bagID 0 (backpack) → (16, 0). Vanilla 1.12 backpack is fixed at 16
@@ -68,7 +54,7 @@ BagInfo ResolveBagInfo(int bagID) {
if (itemID == 0)
return {0, 0};
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return {0, 0};
+5 -5
View File
@@ -50,9 +50,11 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/StatAccum.h"
#include <cstdint>
@@ -80,8 +82,7 @@ bool ItemReadable(const uint8_t *item, const uint8_t *record) {
if (record != nullptr &&
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
return true;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
const uint8_t *descriptor = Item::ObjectFields(item);
return descriptor != nullptr &&
*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
@@ -107,12 +108,11 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
// them off the half-built table avoids any stack interleaving. ---
const int itemID = Item::ID::FromCGItem(item);
const uint8_t *record = (itemID > 0)
? Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID))
? Item::PeekRecord(static_cast<uint32_t>(itemID))
: nullptr;
// Live per-instance descriptor fields.
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
const uint8_t *descriptor = Item::ObjectFields(item);
int stackCount = 0;
bool isBound = false;
if (descriptor != nullptr) {
+5 -18
View File
@@ -54,6 +54,7 @@
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "dbc/Lookup.h"
#include "dbc/Names.h"
#include "event/Custom.h"
#include <cstdint>
@@ -143,17 +144,10 @@ int __fastcall Script_GetRaceInfo(void *L) {
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_FILENAME)
: nullptr;
(raceID > 0) ? DBC::RaceToken(static_cast<uint32_t>(raceID)) : nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_NAMES);
const char *name = DBC::RaceName(static_cast<uint32_t>(raceID));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
@@ -169,17 +163,10 @@ int __fastcall Script_GetClassInfo(void *L) {
return 0; // nil
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME)
: nullptr;
(classID > 0) ? DBC::ClassToken(static_cast<uint32_t>(classID)) : nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
const char *name = DBC::ClassName(static_cast<uint32_t>(classID));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
+2 -12
View File
@@ -53,6 +53,7 @@
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "item/Record.h"
#include "spell/Lookup.h"
#include <cstdint>
@@ -140,17 +141,6 @@ int PushBagItemCursor(void *L) {
return PushItem(L, cgItem);
}
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Builds a basic `"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"` link from
// the cached ItemStats record for `itemID`. No enchant / random-
// suffix decoration — the cursor only stores the bare itemID for
@@ -158,7 +148,7 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
// instance state from. Returns true and writes into `out` (size
// `outSize`) on success; returns false if the item isn't cached.
bool BuildBasicItemLink(uint32_t itemID, char *out, size_t outSize) {
const uint8_t *record = PeekItemRecord(itemID);
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr) return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
+117
View File
@@ -0,0 +1,117 @@
// 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 "Names.h"
#include "Lookup.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
namespace DBC {
namespace {
// Reverse lookup shared by ClassIdForToken / RaceIdForToken: the id whose
// single-string field at `offset` matches `token` (case-insensitive), or 0.
uint32_t IdForStringField(uintptr_t recordsVar, uintptr_t countVar, int offset,
const char *token) {
if (token == nullptr || *token == '\0')
return 0;
const int count = *reinterpret_cast<const int *>(countVar);
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(recordsVar);
if (records == nullptr)
return 0;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = records[i];
if (rec == nullptr)
continue;
const char *field =
*reinterpret_cast<const char *const *>(rec + offset);
if (field != nullptr && Ascii::EqualCI(field, token))
return static_cast<uint32_t>(i);
}
return 0;
}
} // namespace
const char *ClassName(uint32_t classID) {
if (classID == 0)
return nullptr;
return LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT, classID,
Offsets::OFF_CHRCLASSES_NAMES);
}
const char *ClassToken(uint32_t classID) {
if (classID == 0)
return nullptr;
return StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT, classID,
Offsets::OFF_CHRCLASSES_FILENAME);
}
const char *RaceName(uint32_t raceID) {
if (raceID == 0)
return nullptr;
return LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT, raceID,
Offsets::OFF_CHRRACES_NAMES);
}
const char *RaceToken(uint32_t raceID) {
if (raceID == 0)
return nullptr;
return StringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT, raceID,
Offsets::OFF_CHRRACES_FILENAME);
}
uint32_t ClassIdForToken(const char *token) {
return IdForStringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
Offsets::OFF_CHRCLASSES_FILENAME, token);
}
uint32_t RaceIdForToken(const char *token) {
return IdForStringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
Offsets::OFF_CHRRACES_FILENAME, token);
}
const char *AreaName(uint32_t areaID, bool resolveToParent) {
if (areaID == 0)
return nullptr;
// Only the parent-resolving path needs the record; the direct read goes
// straight through LocalizedField (which bounds-checks the id itself), so
// the common !resolveToParent case is a single lookup.
uint32_t use = areaID;
if (resolveToParent) {
const uint8_t *rec = Record(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT, areaID);
if (rec != nullptr) {
const uint32_t parent = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_AREATABLE_PARENT_ID);
if (parent != 0 &&
Record(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT, parent) != nullptr)
use = parent;
}
}
return LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT, use,
Offsets::OFF_AREATABLE_NAMES);
}
} // namespace DBC
+54
View File
@@ -0,0 +1,54 @@
// 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>
// `DBC::*` display-name resolvers for the character/zone DBCs. Thin wrappers
// over `DBC::Record` / `LocalizedField` / `StringField` (see `Lookup.h`) that
// bake in the specific table + field for a game concept, so callers don't
// repeat the `(recordsVar, countVar, id, offset)` tuple. Shared by the
// friends-list and /who surfaces (and any future class/race/zone display).
// All return `nullptr` when the id is 0 / out of range / the field is empty.
namespace DBC {
// Localized class name for a `ChrClasses.dbc` id (e.g. "Mage").
const char *ClassName(uint32_t classID);
// Locale-independent class token for a `ChrClasses.dbc` id (e.g. "MAGE") —
// the key addons use for RAID_CLASS_COLORS and other class tables.
const char *ClassToken(uint32_t classID);
// Localized race name for a `ChrRaces.dbc` id (e.g. "Night Elf").
const char *RaceName(uint32_t raceID);
// Locale-independent race token for a `ChrRaces.dbc` id — the client-filename
// column (e.g. "NightElf", "Scourge"), the race analog of ClassToken and what
// `UnitRace`'s second return / GetPlayerInfoByGUID's englishRace use.
const char *RaceToken(uint32_t raceID);
// Reverse of ClassToken / RaceToken: the `ChrClasses.dbc` / `ChrRaces.dbc` id
// whose client-filename token matches `token` (case-insensitive), or 0 if none
// matches. Walks the table (cheap — a handful of records).
uint32_t ClassIdForToken(const char *token);
uint32_t RaceIdForToken(const char *token);
// Localized zone name for an `AreaTable.dbc` id. When `resolveToParent` is
// true, a sub-area reports its parent zone (so "Goldshire" → "Elwynn
// Forest"); when false, the area's own name is returned (what the engine's
// GetWhoInfo does — /who zones are already zone-level).
const char *AreaName(uint32_t areaID, bool resolveToParent);
} // namespace DBC
+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));
}
+2 -2
View File
@@ -35,6 +35,7 @@
#include "Offsets.h"
#include "Set.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "item/Swap.h"
@@ -72,8 +73,7 @@ const char *ArgString(void *L, int idx) {
int ResolveItemID(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(cgItem);
if (instance == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
+3 -8
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "item/Location.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -58,13 +59,7 @@ int InvMgrSlotCount(const uint8_t *invMgr) {
uint8_t *ResolveByGUID(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto fn = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<uint8_t *>(fn(type, "ItemMgr",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
0x172));
return static_cast<uint8_t *>(Object::ByGuid(type, guid, "ItemMgr", 0x172));
}
// Walks one bag's contents for a matching GUID. Returns the 1-based
@@ -73,7 +68,7 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
// bank bags (linear 63..68) — the only differences are the source of
// the bag's GUID and how the result encodes back into a location.
int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
auto *bag = ResolveByGUID(Offsets::TYPEMASK_CONTAINER, bagGuid);
if (bag == nullptr)
return 0;
auto *bagInvMgr =
+2 -12
View File
@@ -52,6 +52,7 @@
#include "equipmentset/Data.h"
#include "equipmentset/Locations.h"
#include "equipmentset/Set.h"
#include "item/Record.h"
#include <cstdint>
@@ -108,17 +109,6 @@ struct Tally {
uint32_t missingItemIDs[SLOT_COUNT];
};
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Walks a set's slots, classifying each non-empty entry into the
// equipped / in-inventory / ignored / missing buckets via
// `Locations::FindGUID`. For missing slots, also records the saved
@@ -215,7 +205,7 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
unnamedMissing++;
continue;
}
auto *record = PeekItemRecord(id);
auto *record = Item::PeekRecord(id);
if (record == nullptr) {
unnamedMissing++;
continue;
+84 -31
View File
@@ -96,7 +96,9 @@
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "cursor/Info.h"
#include "object/Resolve.h"
#include "spell/AtCursor.h"
#include "spell/AtUnit.h"
#include "tick/WorldTick.h"
@@ -147,15 +149,6 @@ void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char
dst[i] = '\0';
}
// ASCII case-insensitive full-string equality.
bool EqI(const char *a, const char *b) {
for (; *a && *b; ++a, ++b)
if (std::tolower(static_cast<unsigned char>(*a)) !=
std::tolower(static_cast<unsigned char>(*b)))
return false;
return *a == *b;
}
// True if `lname` (already lowercase) is a click "type" action attribute: an
// optional modifier prefix (alt-/ctrl-/shift-, any order/combo) followed by
// "type" and an optional button digit — i.e. anything the click resolver
@@ -410,6 +403,51 @@ 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);
return Object::ByGuid(kUnitOrPlayerMask, guid, "ClassicAPI", 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 +461,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;
}
}
@@ -455,10 +498,10 @@ void BuildModifierPrefix(void *L, char *buf, size_t n) {
// Button name -> attribute suffix (retail's convention: the button number).
const char *ButtonSuffix(const char *btn) {
if (EqI(btn, "RightButton")) return "2";
if (EqI(btn, "MiddleButton")) return "3";
if (EqI(btn, "Button4")) return "4";
if (EqI(btn, "Button5")) return "5";
if (Ascii::EqualCI(btn, "RightButton")) return "2";
if (Ascii::EqualCI(btn, "MiddleButton")) return "3";
if (Ascii::EqualCI(btn, "Button4")) return "4";
if (Ascii::EqualCI(btn, "Button5")) return "5";
return "1"; // LeftButton / unknown
}
@@ -546,10 +589,10 @@ bool CallGlobalNum2(void *L, const char *name, double a, double b) {
// Returns true if it owned the click (so the chained handler is skipped).
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
const char *verb, const char *unit) {
if (EqI(verb, "target")) {
if (Ascii::EqualCI(verb, "target")) {
if (!unit) return false;
// `unit="none"` clears the target (retail's SecureActionButton behavior).
if (EqI(unit, "none")) {
if (Ascii::EqualCI(unit, "none")) {
Game::Lua::CallGlobal(L, "ClearTarget");
return true;
}
@@ -566,17 +609,17 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
return true;
}
if (EqI(verb, "assist")) {
if (Ascii::EqualCI(verb, "assist")) {
if (!unit) return false;
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
return true;
}
if (EqI(verb, "focus")) {
if (Ascii::EqualCI(verb, "focus")) {
if (!unit) return false;
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
return true;
}
if (EqI(verb, "spell")) {
if (Ascii::EqualCI(verb, "spell")) {
if (!unit) return false;
char spell[128];
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
@@ -584,7 +627,7 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
Spell::AtUnit::CastByName(spell, unit);
return true;
}
if (EqI(verb, "item")) {
if (Ascii::EqualCI(verb, "item")) {
char item[128];
int bag, slot;
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
@@ -602,23 +645,33 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
}
return false;
}
if (EqI(verb, "macro")) {
if (Ascii::EqualCI(verb, "macro")) {
char macro[512];
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
return false;
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
// handles named macros and extended macro text; fall back to the stock
// ChatEdit_ParseText path when no RunMacro global is present.
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
// `macrotext` is raw macro text (the modern default). Run it as text
// through the stock chat parser so each line dispatches via
// SlashCmdList — that routes /cast etc. through any addon slash hooks
// (e.g. SuperCleveRoidMacros' conditional /cast). A name-based RunMacro
// (which is what SuperCleveRoidMacros ships) can't interpret text and
// would silently resolve nothing.
if (ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro)) {
RunMacroTextC(L, macro);
return true;
return true;
}
// Deprecated `macro` form: a saved-macro name/index. Prefer an
// addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) to run the
// named macro's body; fall back to the stock parser when none exists.
if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
RunMacroTextC(L, macro);
return true;
}
return false;
}
if (EqI(verb, "stop") || EqI(verb, "stopcasting")) {
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
Game::Lua::CallGlobal(L, "SpellStopCasting");
return true;
}
if (EqI(verb, "menu") || EqI(verb, "togglemenu")) {
if (Ascii::EqualCI(verb, "menu") || Ascii::EqualCI(verb, "togglemenu")) {
if (!unit) return false;
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
@@ -738,7 +791,7 @@ int __fastcall FrameResolver_h(void *frame, void *edx, const char *name) {
const int slot = g_frameResolverOriginal(frame, edx, name);
if (slot != 0) // a real base-frame / subtype script — leave it
return slot;
if (EqI(name, "onattributechanged"))
if (Ascii::EqualCI(name, "onattributechanged"))
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
return 0;
}
+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
+136 -103
View File
@@ -29,8 +29,9 @@
// purely additive; Lua silently drops the extra positional args a zero-param
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
// exact global save/set/restore and its message-handler errfunc; only the
// final push+pcall differs.
// message-handler errfunc, but saves the clobbered globals on the Lua stack
// (not registry refs like the engine) so an out-of-memory longjmp mid-push
// unwinds them for free — no leaked refs, no per-call ref churn. See RunModern.
//
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
@@ -67,17 +68,11 @@ constexpr int kMaxArgs = 19;
// push a value stored under an integer ref (handler / frame object / message
// handler / luaL_ref'd saved globals).
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
// luaL_ref(L, t) → int — pop the top, store it under a fresh int key in table
// `t`, return the key. luaL_unref frees it.
using LuaLRef_t = int(__fastcall *)(void *L, int t);
using LuaLUnref_t = void(__fastcall *)(void *L, int t, int ref);
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
const auto LuaLRef = reinterpret_cast<LuaLRef_t>(Offsets::LUA_REF_REF);
const auto LuaLUnref = reinterpret_cast<LuaLUnref_t>(Offsets::LUA_REF_UNREF);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
@@ -104,40 +99,93 @@ void ArgName(char *buf, int k) {
}
}
// Sets _G[name] = <value currently on the stack top>, capturing the previous
// _G[name] into *outOldRef (a luaL_ref for later restore). Stack-neutral:
// consumes the value, leaves the stack as it was minus that value.
void SetGlobalSaving(void *L, const char *name, int *outOldRef) {
using namespace Game::Lua;
PushString(L, name); // [.., newVal, name]
GetTable(L, GLOBALS_INDEX); // [.., newVal, oldVal]
*outOldRef = LuaLRef(L, REGISTRY_INDEX); // [.., newVal]
PushString(L, name); // [.., newVal, name]
Insert(L, -2); // [.., name, newVal]
SetTable(L, GLOBALS_INDEX); // [..]
// One parsed positional argument from the runner's printf-style format.
// Numbers ('d'/'u'/'f') land in `num`; strings ('s') keep the char* the engine
// handed us (stable for the duration of the call).
struct Arg {
char name[6]; // "arg1".."arg19"
char kind; // 'd' | 'u' | 'f' | 's'
double num;
const char *str;
};
// Decodes `fmt`/`vaPtr` (the runner's %d/%u/%f/%s specs over a packed vararg
// buffer) into `out`, returning the count (capped at kMaxArgs). Unknown specs
// are skipped and consume no vararg — mirroring the engine runner's switch.
// Pure C, touches no Lua state, so it can never throw.
int ParseArgs(const char *fmt, const void *vaPtr, Arg *out) {
if (fmt == nullptr)
return 0;
const char *cur = static_cast<const char *>(vaPtr);
int n = 0;
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
Arg &a = out[n];
switch (*p) {
case 'd':
a.num = static_cast<double>(*reinterpret_cast<const int *>(cur));
cur += 4;
break;
case 'u':
a.num = static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur));
cur += 4;
break;
case 'f':
a.num = *reinterpret_cast<const double *>(cur);
cur += 8;
break;
case 's':
a.str = *reinterpret_cast<const char *const *>(cur);
cur += 4;
break;
default:
continue; // unknown spec — consume nothing
}
a.kind = *p;
ArgName(a.name, n + 1);
++n;
}
return n;
}
// Restores _G[name] to the value at oldRef, then frees the ref.
void RestoreGlobal(void *L, const char *name, int oldRef) {
using namespace Game::Lua;
RawGetI(L, REGISTRY_INDEX, oldRef); // [.., oldVal]
PushString(L, name); // [.., oldVal, name]
Insert(L, -2); // [.., name, oldVal]
SetTable(L, GLOBALS_INDEX); // [..]
LuaLUnref(L, REGISTRY_INDEX, oldRef);
// Pushes a parsed argument's value onto the Lua stack.
void PushArg(void *L, const Arg &a) {
if (a.kind == 's')
Game::Lua::PushString(L, a.str);
else
Game::Lua::PushNumber(L, a.num);
}
// Reimplements the runner tail: set globals (with save/restore), then invoke
// the handler with modern positional args `(self, arg1..argN)` under the
// engine's own message-handler errfunc. `fmt`/`vaPtr` are null for the no-arg
// runner (N == 0).
void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
// Reimplements the runner tail: set the `this`/`arg1..argN` globals, invoke the
// handler with modern positional args `(self, [event,] arg1..argN)` under the
// engine's own message-handler errfunc, then restore the globals. `fmt`/`vaPtr`
// are null for the no-arg runner (N == 0).
//
// Unlike the engine (and our first cut), the old global values are saved on the
// Lua STACK, not in the registry via luaL_ref. A registry save leaks its ref if
// any push before the protected call longjmps — and out-of-memory, the one
// failure that actually fires here, does exactly that. Stack slots need no
// cleanup: an unwinding error resets `L->top` past them for free, so nothing
// leaks and there's no per-call ref churn on the OnUpdate hot path. The cost is
// a deeper stack (up to ~2*N slots live at once), so we CheckStack up front.
//
// Returns false WITHOUT side effects when it declines (no Lua state, or the
// stack can't grow); the caller then runs the original. Every false path is
// before the first push, so falling back never double-runs the handler.
bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
using namespace Game::Lua;
void *L = State();
if (L == nullptr)
return;
return false;
// Headroom for the saved-old block plus the transient set/call pushes.
if (CheckStack(L, 2 * kMaxArgs + 16) == 0)
return false;
// --- _G.this = frame -----------------------------------------------------
const bool haveThis = frame != nullptr;
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
// (frame+0x0C); matching the handler ref against that slot identifies it
@@ -148,65 +196,46 @@ void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
handlerRef;
int selfRef = 0;
int oldThisRef = 0;
if (haveThis) {
selfRef = EnsureLuaRef(frame);
RawGetI(L, REGISTRY_INDEX, selfRef); // push frame object
SetGlobalSaving(L, "this", &oldThisRef);
}
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
// --- _G.arg1..argN from the format --------------------------------------
int oldArgRefs[kMaxArgs];
char argNames[kMaxArgs][6];
int n = 0;
if (fmt != nullptr) {
const char *cur = static_cast<const char *>(vaPtr);
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
bool consumed = true;
switch (*p) {
case 'd':
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur)));
cur += 4;
break;
case 'u':
PushNumber(L, static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur)));
cur += 4;
break;
case 'f':
PushNumber(L, *reinterpret_cast<const double *>(cur));
cur += 8;
break;
case 's':
PushString(L, *reinterpret_cast<const char *const *>(cur));
cur += 4;
break;
default:
consumed = false; // unknown spec — skip, consume nothing
break;
}
if (!consumed)
continue;
ArgName(argNames[n], n + 1);
SetGlobalSaving(L, argNames[n], &oldArgRefs[n]);
++n;
}
}
Arg args[kMaxArgs];
const int n = ParseArgs(fmt, vaPtr, args);
// --- call handler(self, arg1..argN) -------------------------------------
// --- save the globals we're about to clobber, onto the stack ------------
// Absolute indices, live until the closing SetTop(base):
// base+1 = old _G.this (if haveThis)
// savedArgBase+1 .. +n = old _G.arg1..argN
const int base = GetTop(L);
if (haveThis) {
PushString(L, "this");
GetTable(L, GLOBALS_INDEX); // [.. oldThis]
}
const int savedArgBase = base + (haveThis ? 1 : 0);
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
GetTable(L, GLOBALS_INDEX); // [.. oldArgk]
}
// --- set the new globals (each op is stack-neutral) ---------------------
if (haveThis) {
PushString(L, "this");
RawGetI(L, REGISTRY_INDEX, selfRef); // frame object
SetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushArg(L, args[k]);
SetTable(L, GLOBALS_INDEX);
}
// --- call handler(self, [event,] arg1..argN) ---------------------------
const int callBase = GetTop(L); // == base + saved-block size
const int errRef = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
int errIdx = 0;
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
errIdx = base + 1; // absolute index of the errfunc
errIdx = callBase + 1; // absolute index of the errfunc
}
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
if (haveThis)
@@ -217,18 +246,24 @@ void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
PushString(L, "event");
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) { // positional args, re-read from the globals
PushString(L, argNames[k]);
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k)
PushArg(L, args[k]);
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
SetTop(L, base); // drop the message handler + any leftover error object
SetTop(L, callBase); // drop the message handler + any leftover error object
// --- restore globals (reverse order) ------------------------------------
for (int k = n - 1; k >= 0; --k)
RestoreGlobal(L, argNames[k], oldArgRefs[k]);
if (haveThis)
RestoreGlobal(L, "this", oldThisRef);
// --- restore globals from the saved block, then drop it -----------------
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushValue(L, savedArgBase + 1 + k);
SetTable(L, GLOBALS_INDEX);
}
if (haveThis) {
PushString(L, "this");
PushValue(L, base + 1);
SetTable(L, GLOBALS_INDEX);
}
SetTop(L, base);
return true;
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
@@ -238,11 +273,10 @@ RunArgs_t g_origRunArgs = nullptr;
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
if (!g_enabled || handlerRef == 0 || fmt == nullptr) {
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
if (g_enabled && handlerRef != 0 && fmt != nullptr &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
}
RunModern(handlerRef, frame, fmt, vaPtr);
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
@@ -252,11 +286,10 @@ using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void __fastcall Invoke_h(int handlerRef, void *frame) {
if (!g_enabled || handlerRef == 0 || frame == nullptr) {
g_origInvoke(handlerRef, frame);
if (g_enabled && handlerRef != 0 && frame != nullptr &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
}
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr);
g_origInvoke(handlerRef, frame);
}
const Game::HookAutoRegister _runArgsHook{
+127
View File
@@ -0,0 +1,127 @@
// 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 "Offsets.h"
#include <cstdint>
// Shared access to the client's friend list. The friend entries live inline
// in the social singleton (`VAR_SOCIAL_SYSTEM`, shared with the ignore list
// and /who): up to 50 entries from offset 0, stride 0x20. The field offsets
// are verified from GetNumFriends (FUN_005AD000), GetFriendInfo
// (FUN_005AD060), and the count walker FUN_005AE490.
//
// The ignore list lives in the same singleton: a flat array of u64 GUIDs at
// `+0x650`, stride 8, up to 25 entries, terminated by the first zero GUID.
// Vanilla ignores are GUID-keyed, not name-keyed — GetIgnoreName (0x005AD460)
// reads the GUID via FUN_005AE570 (`social + 0x650 + index*8`) and resolves it
// to a name through the NameCache, and GetNumIgnores (0x005AD400 → the count
// walker FUN_005AE550) stops at the first zero GUID and caps at 25.
namespace FriendList {
constexpr int FRIEND_ENTRY_STRIDE = 0x20;
constexpr int MAX_FRIENDS = 50;
constexpr int OFF_FRIEND_CONNECTED = 0x00; // u8 — 0 = offline
constexpr int OFF_FRIEND_STATUS = 0x01; // u8 — bit 0x02 AFK, 0x04 DND
constexpr int OFF_FRIEND_NAME = 0x04; // char*
constexpr int OFF_FRIEND_GUID = 0x08; // u64
constexpr int OFF_FRIEND_LEVEL = 0x10; // i32
constexpr int OFF_FRIEND_CLASS_INDEX = 0x14; // i32 → ChrClasses.dbc
constexpr int OFF_FRIEND_AREA_INDEX = 0x18; // i32 → AreaTable.dbc
constexpr uint8_t FRIEND_STATUS_AFK = 0x02;
constexpr uint8_t FRIEND_STATUS_DND = 0x04;
constexpr int OFF_IGNORE_LIST = 0x650; // u64 GUID array in the social singleton
constexpr int IGNORE_ENTRY_STRIDE = 0x8; // one packed u64 GUID per entry
constexpr int MAX_IGNORES = 25; // 0x19 — GetNumIgnores' cap
// The social singleton, or null before login.
inline const uint8_t *Social() {
return *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_SOCIAL_SYSTEM));
}
// Number of populated friend slots (what GetNumFriends returns).
inline int Count() {
const uint8_t *social = Social();
if (social == nullptr)
return 0;
using CountFn = uint32_t(__fastcall *)(const void *social);
auto fn = reinterpret_cast<CountFn>(
static_cast<uintptr_t>(Offsets::FUN_FRIEND_LIST_COUNT));
return static_cast<int>(fn(social));
}
// Friend entry at 0-based `index`, or null if there is no list or the index
// is out of the fixed slot range. Does not bounds-check against the live
// count — callers that iterate should use `Count()`.
inline const uint8_t *EntryByIndex(int index) {
const uint8_t *social = Social();
if (social == nullptr || index < 0 || index >= MAX_FRIENDS)
return nullptr;
return social + index * FRIEND_ENTRY_STRIDE;
}
// The inline name of the friend with GUID `guid`, or null if that GUID is
// not on the friends list. The list keeps name + GUID for online AND offline
// friends (SMSG_FRIEND_LIST), so this resolves a GUID to a name even when the
// unit is not synced in the object manager and not in the name cache.
inline const char *NameForGuid(uint64_t guid) {
if (guid == 0)
return nullptr;
const int count = Count();
for (int i = 0; i < count; ++i) {
const uint8_t *entry = EntryByIndex(i);
if (entry == nullptr)
break;
if (*reinterpret_cast<const uint64_t *>(entry + OFF_FRIEND_GUID) == guid) {
const char *name =
*reinterpret_cast<const char *const *>(entry + OFF_FRIEND_NAME);
return (name != nullptr && *name != '\0') ? name : nullptr;
}
}
return nullptr;
}
// The ignore-list GUID at 0-based `index`, or 0 if there is no list or the
// index is past the fixed cap. Mirrors FUN_005AE570.
inline uint64_t IgnoreGuidByIndex(int index) {
const uint8_t *social = Social();
if (social == nullptr || index < 0 || index >= MAX_IGNORES)
return 0;
return *reinterpret_cast<const uint64_t *>(
social + OFF_IGNORE_LIST + index * IGNORE_ENTRY_STRIDE);
}
// True if `guid` is on the ignore list. Walks the GUID array, stopping at the
// first zero entry (the list is contiguous — same terminate-on-zero contract
// GetNumIgnores relies on).
inline bool IsGuidIgnored(uint64_t guid) {
if (guid == 0)
return false;
for (int i = 0; i < MAX_IGNORES; ++i) {
const uint64_t entry = IgnoreGuidByIndex(i);
if (entry == 0)
break;
if (entry == guid)
return true;
}
return false;
}
} // namespace FriendList
+166
View File
@@ -0,0 +1,166 @@
// 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_FriendList.GetFriendInfo(name)` / `GetFriendInfoByIndex(index)` — the
// modern FriendInfo table, plus `C_FriendList.GetNumFriends()`.
//
// Reads the friend entries directly (see `FriendList.h`). The class and zone
// names come from the same DBC lookups the engine's own GetFriendInfo uses:
// ChrClasses.dbc by the entry's class index, and AreaTable.dbc by the area
// index — resolved to the parent zone so a sub-area reports its zone. Both
// are locale-applied through `DBC::LocalizedField`.
//
// vanilla 1.12 stores no friend notes, so `notes` is omitted (nil). `mobile`
// and `referAFriend` are always false, and `rafLinkType` is 0 — vanilla has
// none of those systems; the fields exist so modern consumers do not nil-out.
#include "FriendList.h"
#include "Notes.h"
#include "Game.h"
#include "dbc/Names.h"
#include "guid/Guid.h"
#include <cstdint>
#include <cstring>
namespace FriendList::Info {
namespace {
// Push the modern FriendInfo table for `entry` on top of the Lua stack.
void PushFriendInfo(void *L, const uint8_t *entry) {
const bool connected = *(entry + OFF_FRIEND_CONNECTED) != 0;
const char *name =
*reinterpret_cast<const char *const *>(entry + OFF_FRIEND_NAME);
const int level = *reinterpret_cast<const int *>(entry + OFF_FRIEND_LEVEL);
const uint64_t guid =
*reinterpret_cast<const uint64_t *>(entry + OFF_FRIEND_GUID);
const uint8_t status = *(entry + OFF_FRIEND_STATUS);
const uint32_t classIndex =
*reinterpret_cast<const uint32_t *>(entry + OFF_FRIEND_CLASS_INDEX);
const uint32_t areaIndex =
*reinterpret_cast<const uint32_t *>(entry + OFF_FRIEND_AREA_INDEX);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldBool(L, "connected", connected);
Game::Lua::SetFieldNumber(L, "level", static_cast<double>(level));
// Optional strings: leave the field nil (unset) when unresolved rather
// than "" — SetFieldString coerces null to "", which is truthy in Lua and
// would fool an `if info.notes then` presence check. Matches retail, where
// these are nil when absent.
if (const char *className = DBC::ClassName(classIndex))
Game::Lua::SetFieldString(L, "className", className);
// classFilename: the locale-independent class token ("MAGE", "WARRIOR"),
// the key for RAID_CLASS_COLORS and other class tables. nil when unknown,
// so `className` and `classFilename` appear together.
if (const char *classToken = DBC::ClassToken(classIndex))
Game::Lua::SetFieldString(L, "classFilename", classToken);
// Resolve a sub-area to its parent zone for friends (e.g. "Goldshire" ->
// "Elwynn Forest"), matching what the friends UI shows.
if (const char *area = DBC::AreaName(areaIndex, /*resolveToParent=*/true))
Game::Lua::SetFieldString(L, "area", area);
if (guid != 0) {
char buf[Guid::STRING_SIZE];
Game::Lua::SetFieldString(L, "guid",
Guid::FormatAsString(guid, buf, sizeof buf));
}
if (const char *note = Notes::Get(guid)) // client-side note (see Notes.h)
Game::Lua::SetFieldString(L, "notes", note);
Game::Lua::SetFieldBool(L, "afk", (status & FRIEND_STATUS_AFK) != 0);
Game::Lua::SetFieldBool(L, "dnd", (status & FRIEND_STATUS_DND) != 0);
Game::Lua::SetFieldBool(L, "mobile", false);
Game::Lua::SetFieldBool(L, "referAFriend", false);
Game::Lua::SetFieldNumber(L, "rafLinkType", 0);
}
int __fastcall Script_GetNumFriends(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(FriendList::Count()));
return 1;
}
// `C_FriendList.GetNumOnlineFriends()` — how many friends are online. Vanilla
// stores no online count, so we count entries whose `connected` byte is set
// (the same flag GetFriendInfo reports as `connected`).
int __fastcall Script_GetNumOnlineFriends(void *L) {
int online = 0;
const int count = FriendList::Count();
for (int i = 0; i < count; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
if (*(entry + OFF_FRIEND_CONNECTED) != 0)
++online;
}
Game::Lua::PushNumber(L, static_cast<double>(online));
return 1;
}
int __fastcall Script_GetFriendInfoByIndex(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1)); // 1-based
if (index < 1 || index > FriendList::Count()) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *entry = FriendList::EntryByIndex(index - 1);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
PushFriendInfo(L, entry);
return 1;
}
int __fastcall Script_GetFriendInfo(void *L) {
const char *name = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
if (name == nullptr || *name == '\0') {
Game::Lua::PushNil(L);
return 1;
}
const int count = FriendList::Count();
for (int i = 0; i < count; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
const char *fname =
*reinterpret_cast<const char *const *>(entry + OFF_FRIEND_NAME);
if (fname != nullptr && _stricmp(fname, name) == 0) {
PushFriendInfo(L, entry);
return 1;
}
}
Game::Lua::PushNil(L);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_FriendList", "GetNumFriends",
&Script_GetNumFriends);
Game::Lua::RegisterTableFunction("C_FriendList", "GetNumOnlineFriends",
&Script_GetNumOnlineFriends);
Game::Lua::RegisterTableFunction("C_FriendList", "GetFriendInfoByIndex",
&Script_GetFriendInfoByIndex);
Game::Lua::RegisterTableFunction("C_FriendList", "GetFriendInfo",
&Script_GetFriendInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace FriendList::Info
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_FriendList.IsFriend(guid)` — is the given player on the friends list?
//
// Matches the input GUID against each friend entry's GUID (+0x08) — the same
// GUID `SMSG_FRIEND_LIST` populates for online AND offline friends — and also
// falls back to a name compare (resolving the GUID through the NameCache) so
// the answer holds even if a build stores the GUID differently. As a
// ClassicAPI convenience the arg may also be a bare character name, since
// vanilla's friends list is fundamentally name-keyed (AddFriend(name)). See
// `FriendList.h` for the entry layout.
#include "FriendList.h"
#include "Game.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace FriendList::IsFriend {
namespace {
bool IsFriendByGuidOrName(uint64_t guid, const char *name) {
const int count = FriendList::Count();
for (int i = 0; i < count; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
if (guid != 0 &&
*reinterpret_cast<const uint64_t *>(entry + FriendList::OFF_FRIEND_GUID) == guid)
return true;
if (name != nullptr) {
const char *fname = *reinterpret_cast<const char *const *>(
entry + FriendList::OFF_FRIEND_NAME);
if (fname != nullptr && _stricmp(fname, name) == 0)
return true;
}
}
return false;
}
} // namespace
int __fastcall Script_IsFriend(void *L) {
const char *arg = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
if (arg == nullptr || *arg == '\0') {
Game::Lua::PushBool(L, false);
return 1;
}
uint64_t guid = 0;
const char *name = nullptr;
if (Guid::Parse(arg, &guid) && guid != 0)
name = Unit::Identity::NameForGuid(guid); // GUID input → name for the fallback compare
else
name = arg; // not a GUID → treat the string as a character name
Game::Lua::PushBool(L, IsFriendByGuidOrName(guid, name));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_FriendList", "IsFriend", &Script_IsFriend);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace FriendList::IsFriend
+98
View File
@@ -0,0 +1,98 @@
// 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_FriendList.IsIgnored(token)` / `C_FriendList.IsIgnoredByGuid(guid)` — is
// the given player on the ignore list?
//
// The ignore list is a flat array of u64 GUIDs in the social singleton (see
// `FriendList.h`) — vanilla ignores are GUID-keyed, not name-keyed. So
// `IsIgnoredByGuid` is a direct GUID walk, and `IsIgnored` accepts either a
// GUID string or a character name: for a name it resolves each ignored GUID to
// its cached name through the NameCache and compares (the same GUID→name path
// GetIgnoreName uses), so it matches any ignored player the client has synced.
// An ignored player never seen this session has no cached name and so cannot
// match by name — identical to GetIgnoreName returning "Unknown" for them.
#include "FriendList.h"
#include "Game.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace FriendList::IsIgnored {
namespace {
// True if any ignored GUID matches `guid`, or (when `name` is given) resolves
// to `name`. Single walk of the GUID array.
bool IgnoredByGuidOrName(uint64_t guid, const char *name) {
for (int i = 0; i < FriendList::MAX_IGNORES; ++i) {
const uint64_t entry = FriendList::IgnoreGuidByIndex(i);
if (entry == 0)
break; // contiguous list — first zero is the end
if (guid != 0 && entry == guid)
return true;
if (name != nullptr) {
const char *ename = Unit::Identity::NameForGuid(entry);
if (ename != nullptr && _stricmp(ename, name) == 0)
return true;
}
}
return false;
}
} // namespace
// `IsIgnored(token)` — token is a character name or a GUID string.
int __fastcall Script_IsIgnored(void *L) {
const char *arg = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
if (arg == nullptr || *arg == '\0') {
Game::Lua::PushBool(L, false);
return 1;
}
uint64_t guid = 0;
const char *name = nullptr;
if (Guid::Parse(arg, &guid) && guid != 0)
name = Unit::Identity::NameForGuid(guid); // GUID input → name for the fallback compare
else
name = arg; // not a GUID → treat the string as a character name
Game::Lua::PushBool(L, IgnoredByGuidOrName(guid, name));
return 1;
}
// `IsIgnoredByGuid(guid)` — GUID string only.
int __fastcall Script_IsIgnoredByGuid(void *L) {
const char *arg = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
uint64_t guid = 0;
if (arg == nullptr || !Guid::Parse(arg, &guid) || guid == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, FriendList::IsGuidIgnored(guid));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_FriendList", "IsIgnored", &Script_IsIgnored);
Game::Lua::RegisterTableFunction("C_FriendList", "IsIgnoredByGuid",
&Script_IsIgnoredByGuid);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace FriendList::IsIgnored
+206
View File
@@ -0,0 +1,206 @@
// 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/>.
// See `Notes.h`. Mirrors the equipment manager's storage approach
// (equipmentset/Storage.cpp): a small text file under WTF, atomic
// tmp+rename write, path re-resolved on every access so a
// logout→character-select→login (no DLL reload) picks up the new realm.
#include "Notes.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <string>
#include <unordered_map>
namespace FriendList::Notes {
namespace {
struct Entry {
std::string name; // last-seen name, for a readable file (not the key)
std::string note;
};
std::unordered_map<uint64_t, Entry> g_notes;
std::string g_path;
bool g_loaded = false;
const char *ReadAccountName() {
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
}
const char *ReadRealmName() {
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
if (info == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
}
const char *ReadCharacterName() {
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
return (p[0] == '\0') ? nullptr : p;
}
// WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt, or empty until
// the account + realm + character session globals are populated. Per-character
// because the friends list is per-character in vanilla.
std::string ResolveFilePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
const char *character = ReadCharacterName();
if (character == nullptr)
return {};
std::string out = "WTF\\Account\\";
out += account;
out += '\\';
out += realm;
out += '\\';
out += character;
out += "\\ClassicAPI_FriendNotes.txt";
return out;
}
// Notes are a single line each; the file is tab-delimited, so a note must
// carry no tab or newline. Replace any with a space and trim trailing space.
void Sanitize(std::string *s) {
for (char &c : *s)
if (c == '\r' || c == '\n' || c == '\t')
c = ' ';
while (!s->empty() && s->back() == ' ')
s->pop_back();
}
void Load() {
g_notes.clear();
std::ifstream file(g_path);
if (!file.is_open())
return; // first run for this character — empty store is fine
std::string line;
while (std::getline(file, line)) {
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
line.pop_back();
if (line.empty() || line[0] == '#')
continue;
// Format: `0xGUID<tab>name<tab>note`.
const auto t1 = line.find('\t');
if (t1 == std::string::npos)
continue;
const auto t2 = line.find('\t', t1 + 1);
const uint64_t guid = std::strtoull(line.c_str(), nullptr, 0);
if (guid == 0 || t2 == std::string::npos)
continue;
std::string name = line.substr(t1 + 1, t2 - (t1 + 1));
std::string note = line.substr(t2 + 1);
if (note.empty())
continue;
if (static_cast<int>(note.size()) > MAX_NOTE_LEN)
note.resize(MAX_NOTE_LEN);
g_notes[guid] = Entry{std::move(name), std::move(note)};
}
}
void Save() {
if (g_path.empty())
return;
const std::string tmp = g_path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out.is_open())
return;
out << "# ClassicAPI Friend Notes v1\n";
out << "# <0xGUID>\\t<name>\\t<note> (per-character, GUID-keyed)\n";
for (const auto &kv : g_notes) {
char buf[24];
std::snprintf(buf, sizeof(buf), "0x%016llX",
static_cast<unsigned long long>(kv.first));
out << buf << '\t' << kv.second.name << '\t' << kv.second.note << '\n';
}
}
std::remove(g_path.c_str());
std::rename(tmp.c_str(), g_path.c_str());
}
// Re-resolve the path each call; reload when it changes. The DLL survives a
// character/realm switch, so a permanent latch would keep the old character's
// file — matching EquipmentSet::Data::EnsureLoaded.
void EnsureLoaded() {
std::string path = ResolveFilePath();
if (path.empty()) {
// Session globals not ready (login / char-select / loading screen).
// Drop any cached state so a stale path from the PREVIOUS character
// can never be read from or written to — that is the "didn't wipe
// between characters" trap. Nothing queries notes out of world, so
// there is nothing to lose; the next in-world call reloads cleanly.
g_notes.clear();
g_path.clear();
g_loaded = false;
return;
}
if (g_loaded && path == g_path)
return;
// Path changed (different character/account) — Load() clears the map
// before reading, so the new character never inherits the old one's notes.
g_path = std::move(path);
Load();
g_loaded = true;
}
} // namespace
const char *Get(uint64_t guid) {
if (guid == 0)
return nullptr;
EnsureLoaded();
auto it = g_notes.find(guid);
if (it == g_notes.end() || it->second.note.empty())
return nullptr;
return it->second.note.c_str();
}
bool Set(uint64_t guid, const char *name, const char *note) {
if (guid == 0)
return false;
EnsureLoaded();
if (g_path.empty())
return false; // no realm/account yet — nowhere to persist
std::string text = (note != nullptr) ? note : "";
Sanitize(&text);
if (static_cast<int>(text.size()) > MAX_NOTE_LEN)
text.resize(MAX_NOTE_LEN);
if (text.empty()) {
if (g_notes.erase(guid) == 0)
return false; // nothing to clear
} else {
Entry e;
e.name = (name != nullptr) ? name : "";
Sanitize(&e.name);
e.note = std::move(text);
g_notes[guid] = std::move(e);
}
Save();
return true;
}
} // namespace FriendList::Notes
+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>
// Client-side friend notes. Vanilla 1.12 has no server-side note field
// (TBC added it), so we persist notes ourselves — the same pattern the
// equipment manager uses for its sets. Backing file:
// WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt
// Per-character, because the friends list itself is per-character in
// vanilla (each character keeps its own friends). GUID-keyed within that
// file — a friend's GUID is stable and unique on the realm.
namespace FriendList::Notes {
// Longest stored note. Longer input is truncated on Set.
constexpr int MAX_NOTE_LEN = 128;
// The note for a friend GUID, or nullptr if none. Loads the per-realm store
// on first use. The pointer is valid until the next Set or realm switch.
const char *Get(uint64_t guid);
// Set the note for a friend GUID, or clear it when `note` is null/empty.
// `name` is stored alongside so the file stays human-readable. Persists to
// disk immediately. Returns true if the store changed.
bool Set(uint64_t guid, const char *name, const char *note);
} // namespace FriendList::Notes
+89
View File
@@ -0,0 +1,89 @@
// 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_FriendList.SetFriendNotes(name, notes)` / `SetFriendNotesByIndex(index,
// notes)` — write a client-side note for a friend (see `Notes.h`). Passing an
// empty / nil note clears it. Matches retail: the target must already be on
// the friends list, and there is no return value. Fires FRIENDLIST_UPDATE on
// change so the friends UI and note-aware addons refresh.
#include "FriendList.h"
#include "Notes.h"
#include "Game.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace FriendList::SetNotes {
namespace {
// The friend entry whose name matches (case-insensitive), or null.
const uint8_t *FindByName(const char *name) {
if (name == nullptr || *name == '\0')
return nullptr;
const int count = FriendList::Count();
for (int i = 0; i < count; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
const char *fname = *reinterpret_cast<const char *const *>(
entry + FriendList::OFF_FRIEND_NAME);
if (fname != nullptr && _stricmp(fname, name) == 0)
return entry;
}
return nullptr;
}
void ApplyNote(const uint8_t *entry, const char *note) {
if (entry == nullptr)
return;
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
entry + FriendList::OFF_FRIEND_GUID);
const char *name = *reinterpret_cast<const char *const *>(
entry + FriendList::OFF_FRIEND_NAME);
if (Notes::Set(guid, name, note))
Event::Custom::Fire(Event::Custom::LookupByName("FRIENDLIST_UPDATE"), "");
}
int __fastcall Script_SetFriendNotes(void *L) {
const char *name = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
const char *note = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
ApplyNote(FindByName(name), note);
return 0;
}
int __fastcall Script_SetFriendNotesByIndex(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1)); // 1-based
const char *note = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (index >= 1 && index <= FriendList::Count())
ApplyNote(FriendList::EntryByIndex(index - 1), note);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_FriendList", "SetFriendNotes",
&Script_SetFriendNotes);
Game::Lua::RegisterTableFunction("C_FriendList", "SetFriendNotesByIndex",
&Script_SetFriendNotesByIndex);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace FriendList::SetNotes
+1 -1
View File
@@ -121,7 +121,7 @@ using SendQuery_t = void(__thiscall *)(void *self, const char *queryStr);
bool SendQueryInternal(const char *fullQuery) {
auto *system = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_WHO_SYSTEM));
static_cast<uintptr_t>(Offsets::VAR_SOCIAL_SYSTEM));
if (system == nullptr)
return false;
auto fn = reinterpret_cast<SendQuery_t>(Offsets::FUN_WHO_SYSTEM_SEND_QUERY);
+113
View File
@@ -0,0 +1,113 @@
// 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_FriendList.GetWhoInfo(index)` — the modern table form of vanilla's
// `GetWhoInfo(index)`. The vanilla global returns positional values and, most
// notably, no class *token* — only the localized class name. This returns a
// table that includes `filename` (the "MAGE"/"WARRIOR" token), so a /who list
// can be class-colored via RAID_CLASS_COLORS without reverse-mapping the
// localized name.
//
// Reads the same /who result buffer the engine's Script_GetWhoInfo
// (0x005AD6E0) reads (see the WHO_RESULT_* offsets in Offsets.h): an array of
// stride-0xA0 entries with inline name/guild strings and race/class/zone DBC
// indices. `C_FriendList.GetNumWhoResults()` is the companion count.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Names.h"
#include <cstdint>
namespace FriendList::WhoInfo {
namespace {
int WhoResultCount() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT));
}
// `C_FriendList.GetNumWhoResults()` → `numWhos, totalCount`. The first is the
// number of entries you can index with GetWhoInfo; the second is the server's
// total-match count (it can exceed the displayed list). Mirrors the vanilla
// global's two returns.
int __fastcall Script_GetNumWhoResults(void *L) {
const int displayed = WhoResultCount();
const int total = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT) + 4);
Game::Lua::PushNumber(L, static_cast<double>(displayed));
Game::Lua::PushNumber(L, static_cast<double>(total));
return 2;
}
// `C_FriendList.GetWhoInfo(index)` → WhoInfo table, or nil for a bad index.
int __fastcall Script_GetWhoInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1)); // 1-based
if (index < 1 || index > WhoResultCount()) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *entry = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULTS) +
static_cast<size_t>(index - 1) * Offsets::WHO_RESULT_STRIDE);
const char *name = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_NAME);
const char *guild = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_GUILD);
const int level = *reinterpret_cast<const int *>(entry + Offsets::OFF_WHO_LEVEL);
const uint32_t raceID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_RACE);
const uint32_t classID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_CLASS);
const uint32_t zoneID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_ZONE);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "fullName", name); // inline; "" if empty
Game::Lua::SetFieldString(L, "fullGuildName", guild); // "" when guildless
Game::Lua::SetFieldNumber(L, "level", static_cast<double>(level));
// Optional strings stay nil (unset) when the DBC id doesn't resolve, so an
// `if info.filename then` check works — SetFieldString would coerce null
// to "" (truthy).
if (const char *raceStr = DBC::RaceName(raceID))
Game::Lua::SetFieldString(L, "raceStr", raceStr);
if (const char *classStr = DBC::ClassName(classID))
Game::Lua::SetFieldString(L, "classStr", classStr);
if (const char *filename = DBC::ClassToken(classID))
Game::Lua::SetFieldString(L, "filename", filename);
// /who zones are already zone-level, so use the area's own name (no parent
// walk) — matches the engine's own GetWhoInfo.
if (const char *area = DBC::AreaName(zoneID, /*resolveToParent=*/false))
Game::Lua::SetFieldString(L, "area", area);
// No `gender`: vanilla's /who result carries no sex field (the entry ends
// at the zone index), so modern's gender return has no source here.
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_FriendList", "GetWhoInfo",
&Script_GetWhoInfo);
Game::Lua::RegisterTableFunction("C_FriendList", "GetNumWhoResults",
&Script_GetNumWhoResults);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace FriendList::WhoInfo
+4 -7
View File
@@ -14,6 +14,7 @@
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/Resolve.h"
#include <cstdint>
@@ -28,9 +29,6 @@ namespace GameObject::Tooltip {
// on every subsequent `SetX` call — same gating pattern Has/GetUnitGUID,
// Has/GetItem, and Has/GetSpell use.
using ObjectResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
using GameObjectGetName_t = const char *(__fastcall *)(void *gameObject);
// `GameTooltip:GetGameObject()` → (name, id, guid) for whichever gameobject
@@ -72,10 +70,9 @@ static int __fastcall Script_GameTooltipGetGameObject(void *L) {
if (guidLo == 0 && guidHi == 0)
return 0;
auto resolve = reinterpret_cast<ObjectResolveByGUID_t>(
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
void *obj = resolve(Offsets::OBJ_TYPE_GAMEOBJECT, "GameTooltip:GetGameObject",
guidLo, guidHi, 0x172);
void *obj = Object::ByGuid(Offsets::TYPEMASK_GAMEOBJECT,
(static_cast<uint64_t>(guidHi) << 32) | guidLo,
"GameTooltip:GetGameObject", 0x172);
if (obj == nullptr)
return 0;
+6 -26
View File
@@ -55,6 +55,8 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <algorithm>
@@ -65,10 +67,6 @@ namespace Item::AverageLevel {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// 1-based slot range used by both walks.
constexpr int kFirstSlot = 1;
constexpr int kLastSlot = 19;
@@ -119,13 +117,6 @@ constexpr uint32_t kSlotMaskByInvType[29] = {
constexpr int kInvTypeMax = static_cast<int>(sizeof(kSlotMaskByInvType) /
sizeof(kSlotMaskByInvType[0])) - 1;
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Returns (itemLevel, inventoryType) for the CGItem, or (0, 0) if
// the itemID is unresolvable or the cache record hasn't loaded.
struct ItemInfo {
@@ -139,7 +130,7 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return {0, 0};
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
// Equipped or bagged item with no cache record yet — queue a
// warmup so the next call gets populated data. Cheap on cache
@@ -154,22 +145,11 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
// by reading the player's invMgr GUID array directly. Same shape as
// `C_Item.GetItemCount`'s bank path.
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(type, "AverageLevel",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
0x172));
Object::ByGuid(type, guid, "AverageLevel", 0x172));
}
// Collects a single CGItem into `candidates` if it could equip in
@@ -209,7 +189,7 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
if (guidArray == nullptr)
return;
for (int slot = firstSlot; slot <= lastSlot; ++slot) {
const uint8_t *item = ResolveByGuid(Offsets::OBJ_TYPE_ITEM, guidArray[slot]);
const uint8_t *item = ResolveByGuid(Offsets::TYPEMASK_ITEM, guidArray[slot]);
if (item == nullptr)
continue;
CollectBagOrBankItem(item, candidates);
@@ -229,7 +209,7 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
return;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; ++slot) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
const uint8_t *bag = ResolveByGuid(Offsets::TYPEMASK_CONTAINER,
playerGuidArray[slot]);
if (bag == nullptr)
continue;
+2 -2
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -20,8 +21,7 @@
namespace Item::Bound {
static bool ItemIsSoulbound(const uint8_t *item) {
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(item);
if (descriptor == nullptr)
return false;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
+34
View File
@@ -0,0 +1,34 @@
// 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 "CGItem.h"
#include "Offsets.h"
namespace Item {
const uint8_t *InstanceBlock(const uint8_t *cgItem) {
if (cgItem == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_INSTANCE_BLOCK);
}
const uint8_t *ObjectFields(const uint8_t *cgItem) {
if (cgItem == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
}
} // namespace Item
+38
View File
@@ -0,0 +1,38 @@
// 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>
// Accessors for a `CGItem`'s two descriptor-like pointers (see the "Item
// lookups" notes in CLAUDE.md). Every item module used to spell out the same
// `*reinterpret_cast<const uint8_t *const *>(cgItem + OFF_...)` deref; these
// name it once.
namespace Item {
// The item's **instance block** (`CGItem + 0x08` dereferenced): a `uint64`
// item GUID at `+0x00` and the `uint32` itemID at `+0x0C`. This is the block
// the canonical inventory→cache path reads the itemID from. Returns nullptr
// for a null `cgItem`; the stored pointer itself may still be null (callers
// null-check the result before reading fields).
const uint8_t *InstanceBlock(const uint8_t *cgItem);
// The item's **m_objectFields descriptor** (`CGItem + 0x114` dereferenced):
// the UpdateField block holding stack count (`+0x20`), flags (`+0x3C`),
// spell charges (`+0x28`), durability (`+0xA0`), enchantments, etc. Distinct
// from the instance block. Returns nullptr for a null `cgItem`.
const uint8_t *ObjectFields(const uint8_t *cgItem);
} // namespace Item
+2 -2
View File
@@ -15,6 +15,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "CGItem.h"
#include <cstdlib>
@@ -23,8 +24,7 @@ namespace Item::Charges {
int PushChargesForItem(void *L, const uint8_t *item) {
if (item == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(item);
if (descriptor == nullptr)
return 0;
const int32_t stack = static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
+2 -12
View File
@@ -34,6 +34,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Record.h"
#include <cstdint>
@@ -41,17 +42,6 @@ namespace Item::Consumable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
constexpr uint32_t kItemClassConsumable = 0;
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
@@ -62,7 +52,7 @@ int __fastcall Script_C_Item_IsConsumableItem(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
+7 -18
View File
@@ -16,8 +16,10 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -32,8 +34,7 @@ namespace {
int GetStackCount(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(cgItem);
if (descriptor == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
@@ -51,8 +52,7 @@ int GetStackCount(const uint8_t *cgItem) {
int GetUsesPerItem(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 1;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(cgItem);
if (descriptor == nullptr)
return 1;
const int32_t raw = *reinterpret_cast<const int32_t *>(
@@ -97,22 +97,11 @@ int CountInBag(void *L, int bagID, int targetItemID, bool includeUses) {
// object resolver `FUN_OBJECT_RESOLVE_BY_GUID` (the same function
// `GetItemBySlot` would call internally if the gate let us through).
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(type, "ItemMgr",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
0x172));
Object::ByGuid(type, guid, "ItemMgr", 0x172));
}
// Walks linear slots `[firstSlot, lastSlot]` of `invMgr`'s GUID array
@@ -130,7 +119,7 @@ int CountInGuidArray(const uint8_t *invMgr, int firstSlot, int lastSlot,
return 0;
int total = 0;
for (int slot = firstSlot; slot <= lastSlot; slot++) {
const uint8_t *item = ResolveByGuid(Offsets::OBJ_TYPE_ITEM, guidArray[slot]);
const uint8_t *item = ResolveByGuid(Offsets::TYPEMASK_ITEM, guidArray[slot]);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) != targetItemID)
@@ -159,7 +148,7 @@ int CountInBankBags(int targetItemID, bool includeUses) {
int total = 0;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; slot++) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
const uint8_t *bag = ResolveByGuid(Offsets::TYPEMASK_CONTAINER,
playerGuidArray[slot]);
if (bag == nullptr)
continue;
+2 -2
View File
@@ -16,6 +16,7 @@
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -44,8 +45,7 @@ bool ItemLocked(const uint8_t *item) {
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
// block pointer is absent.
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
+2 -2
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "event/Custom.h"
#include "item/Arg.h"
#include "item/CGItem.h"
#include "item/Data.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
@@ -119,8 +120,7 @@ static int ResolveLocationToItemID(void *L, int idx) {
const uint8_t *item = Item::Location::Resolve(L, idx);
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
+2 -2
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -34,8 +35,7 @@ namespace Item::Durability {
int PushDurabilityForItem(void *L, const uint8_t *item) {
if (item == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(item);
if (descriptor == nullptr)
return 0;
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
+3 -16
View File
@@ -17,6 +17,7 @@
#include "item/Cursor.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include <cstdint>
@@ -25,20 +26,6 @@ namespace Item::Equipment {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// Same item-cache peek pattern as `Item::Bag::PeekItemRecord` — we
// don't share because each caller is one-line-different and the
// helper isn't worth a header.
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// `OffhandHasWeapon()` — true iff the player has a one-handed
// weapon (or off-hand-only weapon) equipped in the off-hand slot.
// Returns false for empty off-hand, shields, and held items
@@ -64,7 +51,7 @@ int __fastcall Script_OffhandHasWeapon(void *L) {
return 1;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
@@ -102,7 +89,7 @@ int __fastcall Script_C_Item_IsEquippableItem(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
+2 -2
View File
@@ -14,6 +14,7 @@
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -27,8 +28,7 @@ namespace {
uint64_t ReadItemGuid(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(cgItem);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
+2 -12
View File
@@ -27,6 +27,7 @@
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Spell.h"
#include <cstdint>
@@ -36,17 +37,6 @@ namespace Item::GetData {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
int CurrentLocaleIndex() {
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
}
@@ -377,7 +367,7 @@ int PushDataForItemID(void *L, int itemID) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
+2 -2
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
@@ -23,8 +24,7 @@ namespace Item::ID {
int FromCGItem(const uint8_t *item) {
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
+5 -15
View File
@@ -20,6 +20,7 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
#include <cstdio>
@@ -27,21 +28,10 @@
namespace Item::Info {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
static const char *PushedOrEmpty(const char *s) { return (s != nullptr && s[0] != 0) ? s : ""; }
static int CurrentLocaleIndex() { return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX); }
static const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
static const char *LookupItemClassName(uint32_t classID) {
return PushedOrEmpty(DBC::LocalizedField(
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
@@ -114,7 +104,7 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(itemID));
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
for (int i = 0; i < 6; ++i)
Game::Lua::PushNil(L);
@@ -150,7 +140,7 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
static int PushIconForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return 0;
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
@@ -229,7 +219,7 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
@@ -264,7 +254,7 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0; // nil this call; GET_ITEM_INFO_RECEIVED fires when ready
+10 -7
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "unit/Flags.h"
#include <cstdint>
@@ -41,8 +42,7 @@ int ItemIDForLocalPlayer(void *playerPtr, int slot1Based) {
auto *item = static_cast<uint8_t *>(getBySlot(invMgr, slot1Based - 1));
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
@@ -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)
+2 -12
View File
@@ -46,6 +46,7 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
#include <vector>
@@ -54,10 +55,6 @@ namespace Item::InventoryItemsForSlot {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// invType → slot bitmask. Bit N (0-based) means "this item type fits
// 1-based slot N+1". Values 0..28 cover every InventoryType
// vanilla's Item.dbc emits. Indices outside this range read as 0
@@ -146,18 +143,11 @@ bool PlayerCanEquip(const uint8_t *record) {
return PlayerHasProficiency(classCode, subclass);
}
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return nullptr;
return PeekItemRecord(static_cast<uint32_t>(itemID));
return Item::PeekRecord(static_cast<uint32_t>(itemID));
}
uint32_t ReadInventoryType(const uint8_t *record) {
+2 -12
View File
@@ -17,6 +17,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -24,21 +25,10 @@ namespace Item::InventoryType {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushInventoryTypeForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -12
View File
@@ -17,6 +17,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -24,21 +25,10 @@ namespace Item::Level {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushItemLevelForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -15
View File
@@ -17,6 +17,7 @@
#include "Offsets.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "item/Record.h"
#include <cstdint>
#include <cstdio>
@@ -34,20 +35,6 @@ using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
using BuildNameFromID_t = void(__fastcall *)(char *out, unsigned outSize,
uint32_t itemID, int suffixID);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int dedupFlag);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
const char *FromCGItem(const uint8_t *cgItem) {
@@ -80,7 +67,7 @@ bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize)
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr)
return false;
char name[128];
+8 -25
View File
@@ -16,10 +16,13 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include "../unit/Identity.h"
#include "Arg.h"
#include "CGItem.h"
#include "ID.h"
#include "Link.h"
#include "Record.h"
#include <cstring>
@@ -30,9 +33,6 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *thisInvMgr, int slot);
using PackBagSlot_t = int(__fastcall *)(void *L, void **outInvMgr, int *outLinearSlot,
int *outUnused);
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// Reads `loc.fieldName` and returns it as an int. Returns false via the
// boolean result if the field is missing or non-numeric. Always leaves the
@@ -56,16 +56,8 @@ const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
// --- GUID-walk helpers ---------------------------------------------------
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
uint64_t ReadCGItemGUID(const uint8_t *item) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
@@ -105,12 +97,8 @@ void *EquippedBagInventory(int bagID) {
const uint64_t bagGuid = getBagGuid(static_cast<uint32_t>(bagID - 1));
if (bagGuid == 0)
return nullptr; // no bag equipped in that slot
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto resolve = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *container = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_CONTAINER, "ItemMgr", static_cast<uint32_t>(bagGuid),
static_cast<uint32_t>(bagGuid >> 32), 0x172));
Object::ByGuid(Offsets::TYPEMASK_CONTAINER, bagGuid, "ItemMgr", 0x172));
return ContainerInventory(container);
}
@@ -128,7 +116,7 @@ int GetBagSlotCount(int bagID) {
const int bagItemID = Item::ID::FromCGItem(bagItem);
if (bagItemID == 0)
return 0;
auto *record = PeekItemRecord(static_cast<uint32_t>(bagItemID));
auto *record = Item::PeekRecord(static_cast<uint32_t>(bagItemID));
if (record == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
@@ -170,13 +158,8 @@ bool ParseGUIDString(const char *s, uint64_t *out) {
const uint8_t *ResolveByGUID(uint64_t guid) {
if (guid == 0)
return nullptr;
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto fn = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(fn(Offsets::OBJ_TYPE_ITEM, "ItemMgr",
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32),
0x172));
return static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_ITEM, guid, "ItemMgr", 0x172));
}
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
+1 -1
View File
@@ -70,7 +70,7 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based);
int GetBagSlotCount(int bagID);
// Given a `CGContainer*` (an equipped bag or bank bag, resolved by GUID via
// `FUN_OBJECT_RESOLVE_BY_GUID` with `OBJ_TYPE_CONTAINER`), returns its own
// `FUN_OBJECT_RESOLVE_BY_GUID` with `TYPEMASK_CONTAINER`), returns its own
// inventory-manager object via the container's vtable method at
// `+OFF_CONTAINER_GET_INVENTORY`. The returned object has the same layout as
// the player inventory manager: slot count at `+0x00`, flat GUID array at
+2 -2
View File
@@ -32,6 +32,7 @@
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -54,8 +55,7 @@ using UnlockAll_t = void(__cdecl *)();
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
if (item == nullptr)
return false;
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *inst = Item::InstanceBlock(item);
if (inst == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
+2 -12
View File
@@ -17,6 +17,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -24,21 +25,10 @@ namespace Item::MaxStackSize {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushMaxStackSizeForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -12
View File
@@ -18,6 +18,7 @@
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -25,17 +26,6 @@ namespace Item::Name {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Reads `m_name[0]` directly off a cached record. Items only ever
// populate the first name slot — the array shape (`char *m_name[4]`
// per VanillaHelpers's `ItemStats_C`) is a client-internal layout, not
@@ -44,7 +34,7 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
int PushNameForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+5 -9
View File
@@ -75,7 +75,9 @@
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -196,8 +198,7 @@ bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
uint64_t ItemGUID(const uint8_t *item) {
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
@@ -265,16 +266,11 @@ void AppendGuidArray(const uint8_t *invMgr, int first, int last, uint64_t *out,
AddUnique(out, count, arr[slot]);
}
using ResolveByGuid_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(type, "ItemMgr", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0x172));
Object::ByGuid(type, guid, "ItemMgr", 0x172));
}
// Appends every item the player owns that ISN'T in bags 0..4 — worn
@@ -308,7 +304,7 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
return;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; ++slot) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER, playerArr[slot]);
const uint8_t *bag = ResolveByGuid(Offsets::TYPEMASK_CONTAINER, playerArr[slot]);
if (bag == nullptr)
continue;
auto *bagInvMgr =
+4 -18
View File
@@ -27,10 +27,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Openable.h"
#include "item/Record.h"
#include <cstdint>
@@ -38,29 +40,13 @@ namespace Item::Openable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
namespace {
// Reads `ITEM_FIELD_FLAGS` from the instance descriptor at +0x114.
// Defaults to 0 if `cgItem` is null or the descriptor pointer hasn't
// been populated yet.
uint32_t InstanceFlags(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(cgItem);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -73,7 +59,7 @@ int PushIsItemOpenable(void *L, const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -12
View File
@@ -17,6 +17,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -24,21 +25,10 @@ namespace Item::Quality {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushQualityForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+33
View File
@@ -0,0 +1,33 @@
// 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 "Record.h"
#include "Offsets.h"
namespace Item {
const uint8_t *PeekRecord(uint32_t itemID) {
using GetRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid,
void *callback, void *userData,
int dedup);
auto fn = reinterpret_cast<GetRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
// Null callback = passive peek (no network query). dedup is irrelevant
// with a null callback (nothing is appended to the pending list).
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
} // namespace Item
+34
View File
@@ -0,0 +1,34 @@
// 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>
// Passive read of the client-side item cache (the ItemSparse `ItemStats_C`
// records). Every read-only item accessor used to re-declare the same
// `DBCache_ItemStats_C_GetRecord` typedef and call it with a null callback;
// this names that one passive peek.
namespace Item {
// The cached `ItemStats_C` record for `itemID`, or nullptr if it isn't cached.
// PASSIVE — a null callback means "look up only": it never warms the cache or
// fires SMSG_ITEM_QUERY_SINGLE, so it is safe to call many times per frame
// from tooltips / bag scans. (Eagerly triggering a query from a passive getter
// is the cache-race trap documented in CLAUDE.md — the request/response path
// lives in item/Data.cpp, not here.) Fields are read via the `OFF_ITEMSTATS_*`
// offsets.
const uint8_t *PeekRecord(uint32_t itemID);
} // namespace Item
+2 -12
View File
@@ -17,6 +17,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -24,21 +25,10 @@ namespace Item::SellPrice {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushSellPriceForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -12
View File
@@ -27,6 +27,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -34,21 +35,10 @@ namespace Item::Set {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int PushSetIDForItemID(void *L, int itemID) {
if (itemID <= 0)
return 0;
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+3 -13
View File
@@ -29,6 +29,7 @@
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Data.h"
#include "item/Record.h"
#include "spell/Lookup.h"
#include <cstdint>
@@ -37,17 +38,6 @@ namespace Item::Spell {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
const char *SpellNameForID(int spellID) {
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
@@ -69,7 +59,7 @@ int __fastcall Script_GetItemSpell(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
@@ -113,7 +103,7 @@ int FindOnUseSpellIDInRecord(const uint8_t *record) {
int OnUseSpellIDForItemID(uint32_t itemID) {
if (itemID == 0)
return 0;
const uint8_t *record = FetchItemRecord(itemID);
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr)
return 0;
return FindOnUseSpellIDInRecord(record);
+2 -2
View File
@@ -20,6 +20,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -36,8 +37,7 @@ static int __fastcall Script_GetStackCount(void *L) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
auto *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *descriptor = Item::ObjectFields(item);
if (descriptor == nullptr) {
Game::Lua::PushNumber(L, 0.0);
return 1;
+1 -11
View File
@@ -47,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Record.h"
#include <cstdlib>
#include <cstring>
@@ -55,10 +56,6 @@ namespace Item::StatAccum {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
// Base-record m_statType uses the ItemModType enum: 0=mana, 1=health,
// 3=agility, 4=strength, 5=intellect, 6=spirit, 7=stamina (2 is
// deprecated). Returns nullptr for types with no vanilla key.
@@ -288,13 +285,6 @@ long Value(const Accum *acc, const char *key) {
return 0;
}
const uint8_t *FetchRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
-5
View File
@@ -54,11 +54,6 @@ void AddByKey(Accum *acc, const char *key, long delta);
// Read the accumulated value for `key` (0 for an unknown key). Linear scan.
long Value(const Accum *acc, const char *key);
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
// the cache (returns nullptr for an uncached item) — see the item-cache
// race note in CLAUDE.md.
const uint8_t *FetchRecord(uint32_t itemID);
// Fold an item record's base stats + on-equip-spell stats into `acc`,
// scaled by `sign` (+1 to add, -1 to subtract for a delta). No-op for a
// null record.
+4 -3
View File
@@ -24,6 +24,7 @@
#include "Game.h"
#include "item/Arg.h"
#include "item/Record.h"
#include "item/StatAccum.h"
#include <cstdint>
@@ -70,7 +71,7 @@ int __fastcall Script_GetItemStats(void *L) {
return 0;
}
const int suffixID = StatAccum::ParseRandomSuffixFromLink(L, 1);
const uint8_t *record = StatAccum::FetchRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
@@ -99,8 +100,8 @@ int __fastcall Script_GetItemStatDelta(void *L) {
}
const int suffix1 = StatAccum::ParseRandomSuffixFromLink(L, 1);
const int suffix2 = StatAccum::ParseRandomSuffixFromLink(L, 2);
const uint8_t *r1 = StatAccum::FetchRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = StatAccum::FetchRecord(static_cast<uint32_t>(id2));
const uint8_t *r1 = Item::PeekRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = Item::PeekRecord(static_cast<uint32_t>(id2));
if (r1 == nullptr || r2 == nullptr) {
Game::Lua::PushNil(L);
return 1;
+3 -4
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "unit/Identity.h"
@@ -40,8 +41,7 @@ bool ReadGuid(const void *cgObject, uint32_t *lo, uint32_t *hi) {
if (cgObject == nullptr)
return false;
auto *base = static_cast<const uint8_t *>(cgObject);
auto *inst = *reinterpret_cast<const uint8_t *const *>(
base + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *inst = Item::InstanceBlock(base);
if (inst == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
@@ -257,8 +257,7 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
// server treats as a merge for same-item destinations (up to
// maxStack). Route there for count == srcStack so callers don't
// have to special-case "move everything".
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
auto *srcDescriptor = Item::ObjectFields(srcItem);
const int srcStack = srcDescriptor == nullptr ? 0 :
static_cast<int>(*reinterpret_cast<const uint32_t *>(
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
+6 -18
View File
@@ -18,7 +18,9 @@
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "item/Record.h"
#include "item/TooltipItem.h"
#include "object/Resolve.h"
#include <cstdint>
#include <cstdio>
@@ -138,21 +140,6 @@ static int __fastcall Script_GameTooltipSetInventoryItemByID(void *L) {
}
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
static const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
using ResolveObjectByGuid_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
// Resolves a stored item GUID into a CGItem via the engine's own
// resolver. Same path Item::Count uses for direct bank reads — no
// gating, no inventory walk, works for any object the engine has
@@ -161,8 +148,9 @@ using ResolveObjectByGuid_t = void *(__fastcall *)(int type, const char *debugNa
static void *ResolveItemByGuid(uint32_t guidLo, uint32_t guidHi) {
if (guidLo == 0 && guidHi == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return fn(Offsets::OBJ_TYPE_ITEM, "GameTooltip:GetItem", guidLo, guidHi, 0x172);
return Object::ByGuid(Offsets::TYPEMASK_ITEM,
(static_cast<uint64_t>(guidHi) << 32) | guidLo,
"GameTooltip:GetItem", 0x172);
}
// `GameTooltip:GetItem()` → (name, link, itemID) for whichever item
@@ -248,7 +236,7 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
// OFF_TOOLTIP_COMPARE_SUFFIX — include it (in the link's suffix field)
// when the compare-descriptor flag is set, so the link round-trips the
// suffix for stat comparison. Matches 3.3.5's auction GetItem.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
+2 -15
View File
@@ -42,6 +42,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -49,25 +50,11 @@ namespace Item::Uniqueness {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid,
void *callback, void *userData,
int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(
static_cast<uintptr_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD));
auto *cache = reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ITEMDB_CACHE));
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
// so the Lua side sees the modern "MayReturnNothing" behaviour.
int ReadMaxCount(uint32_t itemID) {
const uint8_t *record = PeekItemRecord(itemID);
const uint8_t *record = Item::PeekRecord(itemID);
if (record == nullptr) {
Item::Data::WarmCache(itemID);
return -1;
+2 -13
View File
@@ -40,6 +40,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Record.h"
#include "item/Spell.h"
#include "spell/Lookup.h"
@@ -49,21 +50,9 @@ namespace Item::Usable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
// Same item-cache peek pattern as the other item modules (no shared
// helper — each call site is one line different). Sync, no load fired.
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
@@ -91,7 +80,7 @@ Usability ComputeUsability(int itemID) {
if (itemID <= 0)
return r;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return r; // not cached → sync "false", no load fired
+2 -2
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
@@ -52,8 +53,7 @@ EnchantInfo ReadTempEnchant(int paperdollSlot) {
const uint8_t *item = Item::Location::ResolveEquipmentSlot(paperdollSlot);
if (item == nullptr)
return r;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *desc = Item::ObjectFields(item);
if (desc == nullptr)
return r;
auto *slot = reinterpret_cast<const uint32_t *>(desc + OFF_TEMP_ENCHANT_SLOT);
+2 -20
View File
@@ -47,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Names.h"
#include "event/Custom.h"
#include "unit/Identity.h"
@@ -107,25 +108,6 @@ uint32_t ClassIdFromRecord(const uint8_t *rec) {
: 0;
}
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
// the ID is out of range or the record is missing.
const char *ClassTokenForId(uint32_t classId) {
if (classId == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
if (records == nullptr || static_cast<int>(classId) > count)
return nullptr;
const uint8_t *rec = records[classId];
if (rec == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_CHRCLASSES_FILENAME);
}
constexpr int kNameLen = 48; // engine NameCache inline name[48]
void CopyName(char *dst, const char *src) {
@@ -506,7 +488,7 @@ int __fastcall Script_GetPlayerInfo(void *L) {
Game::Lua::PushString(L, r.name); // 1 name
else
Game::Lua::PushNil(L);
const char *classToken = ClassTokenForId(r.classId); // 2 class token
const char *classToken = DBC::ClassToken(r.classId); // 2 class token
if (classToken != nullptr)
Game::Lua::PushString(L, classToken);
else
+2 -18
View File
@@ -14,6 +14,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include <cstdint>
@@ -62,19 +63,6 @@ using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
// `ClntObjMgrObjectPtr(typeMask, debugMessage, guid_lo, guid_hi, debugCode)`
// — resolves a 64-bit GUID to the loaded `CGObject_C *` it currently
// refers to, filtered by `typeMask`. Returns NULL when no live object
// matches the GUID *and* the typeMask. Splitting the GUID into lo/hi
// DWORDs on the stack is equivalent to the engine's
// `__fastcall(uint32_t, const char*, uint64_t, int)` signature once the
// `uint64_t` argument is placed in the same stack slots.
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
// Reads `(unit.m_objectFields[+0x224] & UNIT_DYNFLAG_LOOTABLE) != 0`
// — the server-driven bit it sets when the local player has loot
// rights on this unit. Works uniformly for live and dead CGUnit
@@ -157,11 +145,7 @@ struct ScanCtx {
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
void *obj = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (obj == nullptr)
return 1; // GUID isn't a unit; continue
+3 -15
View File
@@ -40,6 +40,7 @@
#include "../event/Custom.h"
#include "../event/SignalHook.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include "../tick/WorldTick.h"
#include <cstdint>
@@ -104,11 +105,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
@@ -290,11 +286,7 @@ void TryStartNext() {
}
void StartLoot(uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
void *target = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (target == nullptr) {
TryStartNext();
return;
@@ -423,11 +415,7 @@ struct EnumCtx {
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
void *obj = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (obj == nullptr)
return 1;
if (!IsLootableUnit(obj))
+2 -10
View File
@@ -14,6 +14,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include <cstdint>
@@ -21,11 +22,6 @@ namespace Loot::Unit {
namespace {
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
@@ -71,11 +67,7 @@ int __fastcall Script_LootUnit(void *L) {
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return 0;
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
void *target = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (target == nullptr)
return 0;
+2 -10
View File
@@ -23,6 +23,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../object/Resolve.h"
#include "../tick/WorldTick.h"
#include <cstdint>
@@ -38,11 +39,6 @@ enum class State {
WaitingOpen,
};
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
@@ -176,11 +172,7 @@ int __fastcall Script_LootUnitItem(void *L) {
return 1;
}
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
void *target = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0);
if (target == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
+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;

Some files were not shown because too many files have changed in this diff Show More