Compare commits

...
172 Commits
Author SHA1 Message Date
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
Brues aae4eb8a2b fix(util): preserve nil holes in vararg forwarding; drop no-op unpack ranges
On this Lua 5.0 build, unpack() ignores its explicit i/j arguments entirely
and takes its element count from getn() -- which returns the table's `.n`
field, or border-searches to the first nil when `.n` is absent. Verified in
the binary (luaB_unpack 0x007030a0 -> luaL_getn 0x006f5050) and in-game
(unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).

- GenerateClosure built its forwarding table `rest` with no `.n`, so
  unpack(rest, 1, restN) border-searched and silently dropped every value
  past the first nil hole in the payload -- despite the comment claiming
  embedded nils survive. It now stamps `all.n` and unpack(all) forwards
  holes intact.

- Every other unpack(x, 1, x.n) / unpack(x, 1, count==x.n) call was a no-op:
  the table already carried `.n`, so unpack(x) is identical. Simplified
  across FunctionUtil, Pools, CallbackRegistry, and TableUtil's SafeUnpack,
  and corrected the header comments that recommended the redundant form.
2026-08-04 18:13:14 -05:00
Brues 0a31ee8a89 fix(events): stop EventRegistry dispatch on PLAYER_LOGOUT to prevent #132 crashes
A callback firing during the engine's logout teardown can touch state
that has already been freed, faulting with ERROR #132 (ACCESS_VIOLATION).
A dedicated frame owns PLAYER_LOGOUT and tears down frameEventFrame's
event delivery (UnregisterAllEvents + clear OnEvent) so nothing dispatches
during teardown. A separate frame keeps this decoupled from the
ref-counted consumer event set, which may no longer hold PLAYER_LOGOUT.
2026-08-04 13:25:02 -05:00
Brues 969273f5f7 tradeskilllink: link reagents in chat 2026-08-04 12:28:43 -05:00
Brues a4760386ed tradeskilllink: Properly format links for SetHyperlink 2026-08-04 11:55:47 -05:00
Brues f9ef12bc9b itemmixin: GetItemLink/GetInventoryTypeName switched to select
These were backported before select was
2026-08-03 22:39:16 -05:00
Brues 89d86bffb7 addon-compat: unregister ShaguTweaks libpredict's backported TBC events
ShaguTweaks' libpredict sender frame (ShaguTweaksPredictionSender) registers
TBC-era events alongside its vanilla ones — COMBAT_LOG_EVENT_UNFILTERED and
UNIT_SPELLCAST_START/STOP/FAILED/INTERRUPTED/SENT (libpredict.lua:342-347). In
stock 1.12.1 those never fire, so registering them was a harmless no-op and only
the vanilla SPELLCAST_* path ran. ClassicAPI now backports those events, so
libpredict's TBC branches fire too and double-process cast/heal predictions.

Unregister the six TBC events on libpredict.sender once ShaguTweaks loads,
restoring vanilla-only behavior. Guarded on the addon existing and the
libpredict table being present.
2026-08-03 17:06:07 -05:00
Brues a0d8d6d723 fix(events): forward leading-nil event payloads to callbacks
EventRegistry callbacks (RegisterOnceFrameEventAndCallback, etc.) dropped any
payload whose first arg was nil — e.g. this client's reload PLAYER_ENTERING_WORLD
(arg1=nil, arg2=1) delivered nil to every callback parameter. Root cause is a
WoW Lua 5.0 quirk: unpacking a table whose hole lands right after a present
element into a `func(first, ...)` collapses the receiving vararg count to 0, so
the payload after `owner` was silently lost.

Fix both build-then-unpack sites in CallbackRegistry to pass the leading arg
explicitly and the payload as the unpacked tail:
- TriggerEvent Function branch: func(owner, unpack(arg, 1, arg.n))
- GenerateClosure: func(bound[1], unpack(rest, 1, restN))

Verified in-game: RegisterOnceFrameEventAndCallback("PLAYER_ENTERING_WORLD", cb)
now delivers arg2=1 to the callback's 2nd parameter on reload.
2026-08-03 14:23:12 -05:00
Brues e0e4fbcf84 docs: list the new frame-attribute + modern-script-arg APIs
The attribute methods and the modern-script-args toggle shipped on this
branch but weren't in the README index, and the toggle wasn't documented
at all.

- README: add frame:SetAttribute / SetAttributeNoHandler / ClearAttribute /
  GetAttribute, the OnAttributeChanged script, and SetModernScriptArgs /
  GetModernScriptArgs to the Frame row.
- docs/API.md: document SetModernScriptArgs(enable) / GetModernScriptArgs()
  (opt-in modern positional handler args) with a Frame TOC entry. The
  attribute methods and OnAttributeChanged were already covered.
2026-08-03 01:57:45 -05:00
brues-code 1da06d4a73 frame: attribute backport, modern script args, and SMSG hook consolidation (#13)
* frame: backport Frame:SetAttribute/GetAttribute + unit-frame mouseover/click

Adds native SetAttribute / SetAttributeNoHandler / GetAttribute on the base
Frame registry: a per-frame, case-insensitive value store with the 3.3.5
prefix/name/suffix getter precedence, stored on the frame's own Lua table.

A `unit` attribute makes the frame a mouseover source. A WorldTick poll of the
engine's mouse-focus frame (GetMouseFocus's global) calls the real mouseover
setter (FUN_00492890) for its unit -- 1:1 with hovering the 3D model: model
highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the native GUID slot.
Stomp-proof (the engine's 3D-hover setter is event-driven) and independent of
the frame's scripts, so an addon setting its own OnEnter can't break it.

A `type1`/`type2`/`type` attribute installs a chained OnClick on that frame only
(PCall-guarded, Button-only) that dispatches target / assist / focus on the
unit at click time, then runs any prior handler.

No global hooks: mouseover reads the mouse-focus global; clicks use the frame's
own OnClick. Deliberately does NOT MinHook the button-click dispatcher
(FUN_00779540) -- SuperWoW's click-casting owns that prologue and a second
inline hook corrupts the trampoline (ERROR #132).

* frame: extend attribute backport (one-verb click dispatcher + OnAttributeChanged)

The `type*` click path now mirrors retail's SecureActionButton_OnClick:
resolve ONE verb per click from the modifier/button-qualified `type`
attribute and perform it, instead of a fixed `type1=target`.

- Modifier/button resolution: `[alt-/ctrl-/shift-]type[1..5]` with
  prefix..name..suffix -> name..suffix -> name precedence, read from the
  live key state and the click's button.
- Verbs: target (with the engine's default-interaction precedence -
  cast a pending spell / drop a cursor item on the unit instead of
  switching target), assist, focus, spell, stopcasting, macro, and menu.
- Own-the-click: when a verb resolves we perform it and do NOT chain the
  frame's previous OnClick, so a configured `type1` no longer double-
  dispatches alongside an addon's own conditional click handler.
- Self-healing wire: addons that re-SetScript OnClick (pfUI on every raid
  relayout) clobber our closure; WireOnClick re-wraps a clobbered Lua
  handler but skips its own C closure, so re-setting `type*` recovers
  without double-chaining.

Dispatch goes straight to C++ where we own the code - no Lua round-trip
through the global table: spell -> Spell::AtUnit::CastByName (a native
unit-targeted cast; the unit's GUID is fed to the engine's dispatcher so
there's no target juggling, and ground-target spells land at the unit's
feet), focus -> Unit::Focus::Set, and the target verb's predicates ->
Spell::AtCursor::IsPlacementActive / Cursor::Info::HasItem. Only genuine
engine entries (TargetUnit/AssistUnit/SpellTargetUnit/DropItemOnUnit/
SpellStopCasting) and addon-provided functions still go through Lua
globals. Spell::AtUnit gains a C++ header sharing its CastCore with the
C_Spell.CastAtUnit Lua entry.

macro verb: takes the content from the `macrotext`/`macro` attribute and
prefers an addon-provided RunMacro (SuperCleveRoidMacros etc. - handles
named macros and extended macro text), falling back to running the text
natively when no RunMacro global is present. The native path runs each
line through the stock FrameXML ChatEdit_ParseText via a throwaway
edit-box (GetText closure + no-op __index), so it needs no addon.

menu/togglemenu verb: pops the standard unit dropdown at the cursor via
the new ClassicAPI_ToggleUnitMenu (Util/SecureUnitMenu.lua), resolving
the unit -> menu type with Blizzard's own generic resolver
(self->SELF, pet->PET, grouped player->PARTY, other player->PLAYER,
NPC->RAID_TARGET_ICON). Keeps the FrameXML dropdown logic in Lua with
the C side as a thin call.

OnAttributeChanged is now a real SetScript/GetScript/HookScript-able
frame script on every frame: SetAttribute fires it, SetAttributeNoHandler
suppresses it. Made settable by co-hooking the base-frame script-name
resolver FUN_0076a0d0 (the one every frame type delegates through) and
handing back an external per-frame {handler, context} cell for that name -
frames are immortal in 1.12, so the cell never goes stale. One hook does
it (vs the tooltip OnTooltipSet* work's five) because we already own the
firing side. 1.12 convention: the handler reads this = frame, arg1 = name,
arg2 = value. Recursion-guarded so a handler may itself SetAttribute.

* frame: add Frame:ClearAttribute (retail 11.2.0)

`cleared = frame:ClearAttribute(name)` removes an attribute and returns
whether it was set. Registered on the base Frame method registry next to
SetAttribute/GetAttribute, so every frame has it.

Routes through DoSet's nil-value path (removes the stored key, drops a
`unit` mouseover binding) but does NOT fire OnAttributeChanged — verified
against a live retail 11.2 client: SetAttribute fires the handler,
ClearAttribute fires nothing, and the return is true for a set attribute /
false when there was nothing to clear.

* frame: store attributes in the Lua registry, hidden from Lua

Attributes were kept as a subtable on the frame's own Lua table under a
control-char key ("\1ClassicAPIAttributes"). That key, while collision-
safe, is still enumerable via pairs(frame) and readable/writable from Lua.

Move the store to registry[frame] in the Lua registry (LUA_REGISTRYINDEX),
which has no _G path — so the attribute map is invisible to pairs(frame)
and can't be read or tampered with from script, matching how retail keeps
attributes C-side. Only PushSub changed (create/fetch now key the registry
by the frame table); GetAttribute/DoSet/CopyAttr sit on top unchanged.
1.12 frames are immortal, so the strong registry key never leaks. The dead
kAttrKey constant is removed.

Verified in-game: SetAttribute/GetAttribute still round-trip, and a
pairs(frame) scan no longer finds any ClassicAPI key.

* frame: add the `item` click verb (SecureActionButton item action)

type*="item" uses the modifier-qualified `item` attribute:
- a "bag slot" string (e.g. "0 1") -> UseContainerItem(bag, slot);
- otherwise an item name / itemID / link -> C_Item.UseItemByName(item, unit),
  passing the unit as the use target (self-use items ignore it, targeted
  ones like scrolls/bandages apply to it);
- the deprecated `bag` + `slot` attributes are honored when `item` is unset.

Both bag paths go through lua_call (new CallTableFunc2Str / CallGlobalNum2)
rather than a direct C++ call: the item-use bag walk does SetTop(L, 0), so
it needs its own Lua stack frame — a direct call would wipe the dispatcher's
stack. (spell stays direct because Spell::AtUnit is stack-free; the helper
comment records the distinction.) Retail's "bag slot" string is the
preferred form; the deprecated per-attribute form is supported too.

* docs: format the attribute click verbs as a table

The verbs were one long paragraph of nested parentheticals - hard to scan.
Replace it with a Verb | Extra attributes | Action table so each verb's
inputs and behavior are legible at a glance. Content-equivalent; no API change.

* frame: clear target on the `target` verb when unit is "none"

Retail's SecureActionButton target action treats a "none" unit as "clear
target". Match it: the `target` verb calls ClearTarget() when the resolved
`unit` is "none" (case-insensitive), before the normal cursor/spell
precedence. Mouseover already handles "none" implicitly (it resolves to
GUID 0 = no unit). Docs' target-verb row updated.

* frame: pass modern positional args to script handlers

Vanilla always invokes frame-script handlers with zero Lua args; the
handler reads this/arg1/event as globals. Modern addon ports write
SetScript("OnMouseWheel", function(self, delta) ...) and get nothing.

Co-hook both script runners -- FUN_FRAME_INVOKE_SCRIPT (no-arg scripts)
and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) -- and reimplement
their pcall tail: keep the engine's exact this/argN global save-set-
restore and message-handler errfunc, but additionally push self +
arg1..argN as real positional arguments and pcall with nargs = 1+N.
Purely additive: a zero-param vanilla handler ignores the extras and
reads globals; a modern (self, delta) handler binds them.

OnEvent gets (self, event, arg1..argN), detected structurally by
matching the invoked handler ref against the frame OnEvent slot
(frame+0x0C) -- exact, since each SetScript makes a distinct ref.

Runtime switch SetModernScriptArgs()/GetModernScriptArgs(), default on;
when off both detours are a straight passthrough to the original runner,
which matters because FUN_FRAME_RUN_SCRIPT_ARGS is the hottest Lua path
in the engine (every OnUpdate frame). Verified in-game across
OnMouseWheel/OnClick/OnValueChanged/OnUpdate/OnEnter/OnLeave/OnEvent.

* net: consolidate spell SMSG hooks onto one dispatch funnel

The spell subsystem's per-opcode SMSG handlers (SpellGo, SpellStart,
SpellDelayed, ChannelStart/Update, SpellFailure, SpellFailedOther,
SpellCooldown) were each co-hooked directly -- and nampower (hadesmem)
plus SuperWoW detour the same prologues. Two inline hookers on one
prologue is the documented MinHook collision risk, and a whole-protocol
bisect fingered exactly this cluster as the crash-suspect surface.

Add Net::PacketDispatch: a receive-side mirror of Net::SendObserver that
co-hooks the single incoming-SMSG dispatch funnel FUN_NET_MESSAGE_DISPATCH
(0x00537AA0) -- a chokepoint nampower does NOT hook (it detours the leaf
handlers) -- and fans each packet out to opcode subscribers with the
cursor at the body and centralized save/restore.

Migrate all eight handlers to subscribers: aura/Source (SpellGo, which
re-decodes the packed guids + spellId the leaf handler used to hand it
pre-decoded), spell/Cast (Start/Delayed/Channel*/Failure*), and
lossofcontrol (Cooldown). The parse bodies are unchanged; only the hook
site moved. Subscribers run before the engine's leaf handler instead of
after a co-hook's original -- verified order-independent for all eight
(self-contained cache/state updates; SpellGo's aura-application handoff
rides a separate SMSG_UPDATE_OBJECT).

Cuts the ClassicAPI-nampower shared spell prologues from 10 to 3 (the
OnAura* method hooks, which have no packet chokepoint to relocate to).
One hook on an uncontended function replaces eight on contended ones.

Verified in-game: cast bars, channels (incl. Mind Control cast-then-
channel), pushback, remote-cast interrupt eviction, and C_LossOfControl
school lockouts all intact.

* ScriptArgs is back to opt-in

* frame: fix ScriptArgs comments to match opt-in default

* frame/net: dedupe packet observers, reuse Lua/token helpers, fix attr edge cases

Post-review cleanup of the attribute + SMSG-consolidation work.

- net: extract the shared packet-observer plumbing (PacketObserver.h) — the
  intrusive subscriber list, Subscribe(), and the read-opcode / reset-cursor /
  fan-out / restore loop — so Net::PacketDispatch (SMSG, u16) and
  Net::SendObserver (CMSG, u32) each reduce to a one-line hook thunk over
  FanOutByOpcode<OpcodeT>. Drops the dead per-namespace Callback aliases
  rather than keeping them as compat shims.

- Game::Lua: promote PushGlobalFunction / CallGlobal / CallGlobalString into
  the shared Lua helpers (a pattern several modules open-code); Frame
  attributes use them instead of private copies.

- frame/Attributes: resolve unit tokens through Unit::Identity::GuidForToken
  (engine resolver + player fast-path) instead of a duplicate raw-cast wrapper.

- frame/Attributes: wire the chained OnClick for every "type" action attribute
  the click resolver reads (IsTypeAttr: modifier prefixes + type1..type5), not
  just type/type1/type2 — a middle-click or modifier-only binding now installs.

- frame/Attributes: FireAttributeChanged snapshots and restores the caller's
  arg1/arg2 around the fire, so calling SetAttribute from inside a running
  script handler no longer clobbers its globals.
2026-08-03 00:37:36 -05:00
Brues 960b37d7e6 README: trim addon helper list and remove legacy notes
Remove Mixin/CreateFromMixins from the AddOns helper list and streamline the surrounding text. Drop outdated paragraphs describing engine-dependent addon features and the note about the triple-`!!!` folder prefix/load-order. This is a documentation cleanup only; no code or behavior changes.
2026-08-02 01:19:16 -05:00
Brues 0c27d61839 unit: add markN raid-target-marker unit tokens
"mark1".."mark8" (star..skull) now work as unit tokens against the whole
Script_Unit* surface, and fire per-token unit events (UNIT_HEALTH, ...)
with arg1 == "markN" -- the same TokenObserver path focus/nameplateN use.

Resolution: a markN branch in the FUN_TOKEN_TO_GUID hook
(unit/TokenExtensions.cpp) reads the engine's 8-slot marker table
(VAR_RAID_TARGET_GUIDS = 0x00B71368, mark{1..8} = table[N-1]), so every
stock Unit* gains the token for free and "mark1target" composes via the
existing suffix walker.

Events: unit/RaidTarget.cpp runs a WorldTick watcher that diffs the marker
table and attaches/detaches one descriptor observer per marked unit,
deferring registration until the unit is in the object table (a unit can
be marked while out of range and enter later) -- the same range handling
Focus does, x8. One distinct callback per slot fires its fixed "markN"
token. Idle (no markers set) costs 8 compares and no object lookups; a
marker on a non-unit is never observed.

Verified the array shape against the binary (reverse lookup FUN_004bb190
walks 8 uint64 slots) and against nampower's CGRaidInfo_m_raidtargets.
Docs added under Unit tokens (markN); README token table updated.
2026-08-01 23:55:44 -05:00
Brues f02f576e70 addon: hide + force-enable embedded !!!ClassicAPI so it can't be disabled
The bundled library provides FrameXML-compat fixes (e.g. the tooltip
mouseover guard) that the DLL and other addons rely on; letting a user
uncheck it at character-select would silently break them.

FinalizeEmbeddedEntry now sets entry+0x29 (filter-out) to hide it from
the AddOns list -- the flat-display builder FUN_0051da70 copies only
+0x29==0 entries into GetNumAddOns/GetAddOnInfo -- and entry+0x2b
(DefaultState) enabled. A co-hook on the per-character enable resolver
FUN_ADDON_ENABLE_RESOLVE (0x0051e470) force-returns "enabled" for
!!!ClassicAPI, defeating any stale WTF disable-override that hiding
alone would otherwise leave stuck off.

Hiding only affects the index-based display array; the addon hash table
keeps the entry, so ## Dependencies: !!!ClassicAPI, IsAddOnLoaded, and
GetAddOnInfo-by-name all still resolve. README updated to match.
2026-08-01 22:53:26 -05:00
Brues 0bd85dd4f3 tooltip: guard mouseover-clear recolor, fix white name flash (#12)
The DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover loss (retail
parity). The stock FrameXML GameTooltip OnEvent recolors the name
unconditionally via GameTooltip_UnitColor("mouseover"), which returns
white when no mouseover exists -- so the loss fire (and the transient
clear when clicking a unit) flashed NPC and PvP-flagged-player names
white during the tooltip fade.

Wrap the stock OnEvent in Util/GameTooltip.lua: swallow the event when
UnitExists("mouseover") is false, otherwise delegate to the original
untouched. The name keeps its color through the loss window and
recolors normally when a mouseover resolves again.
2026-08-01 22:53:15 -05:00
brues-code 29e3dab24a UNIT_SPELLCAST_* event family + GUID unit tokens (#11)
* spell: backport UNIT_SPELLCAST_* events for the local player

Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).

The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
  - START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
    against the previous snapshot.
  - SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
    Deferred for channels so it lands after CHANNEL_START (modern order).
  - INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
    co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
    (repeated per retry, reusing the cast guid); a pre-cast rejection fires
    FAILED.
  - SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
    forward so all of a cast's events share one guid.
  - CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
    re-anchors g_channel.endMs to the server's remaining time so channel
    pushback (which shortens vanilla channels) shows in UnitChannelInfo.

Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.

Supporting changes:
  - Net::SendObserver: shared co-hook on the NetClient send funnel with an
    AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
    capture) both watch outgoing packets without owning the hook. Extracted
    from ComboDuration's private hook.
  - Cast.cpp: player channel now self-expires at its computed endMs in the
    poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
    trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
    ms tick past 2^31 can't misfire it.

Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.

* spell: extend UNIT_SPELLCAST_* to non-player units (phase 2)

Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.

Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.

Remote events are derived from Spell::Cast's existing packet hooks:
  - START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
  - SUCCEEDED from the SPELL_GO hook (Aura::Source)
  - STOP / CHANNEL_STOP at the computed end, or on abort
  - INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
    failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.

Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.

Also in this change:
  - castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
    time-based castUID (low 23 bits = UNIX second, high bits = per-second
    counter, matching retail's structure), threaded across a cast's events
    (player and remote converge on the same value); FAILED is Type 2 with
    a flat incrementing integer.
  - Channels never fire INTERRUPTED (player or remote) -- retail emits
    only CHANNEL_STOP whether a channel completes or is cut short.

arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.

* spell: return the castGUID as UnitCastingInfo's castID

UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.

UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.

Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).

* spell: add UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR events

Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".

Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.

The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.

* spell: fire UNIT_SPELLCAST_FAILED_QUIET for the quiet result whitelist

Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.

Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
  CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)

SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.

* unit: accept raw GUID literals as unit tokens

Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.

Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
2026-07-31 21:38:44 -05:00
Brues 555ea02447 loot: add C_Loot.LootAllCorpses to auto-loot nearby corpses
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.

Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.

Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).

Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
2026-07-31 14:51:24 -05:00
Brues e3f5318747 bag: gate BAG_UPDATE_DELAYED on VAR_IN_WORLD, drop the PEW latch
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.

The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).

Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
2026-07-31 13:04:15 -05:00
Brues cd674aa115 hearth: gate HEARTHSTONE_BOUND on in-world flag, not location diff
Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.

A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.

Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
2026-07-31 12:58:32 -05:00
Brues f91ffd3189 bag: gate BAG_UPDATE_DELAYED on PLAYER_ENTERING_WORLD, not VAR_IN_WORLD
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).

Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
2026-07-30 20:13:05 -05:00
Brues 8361ae01eb equipment: unregister equip observers before re-registering on reload
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.

Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
2026-07-30 20:02:59 -05:00
Brues 5d785ea0d3 bag: hold BAG_UPDATE_DELAYED until in-world
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.

Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
2026-07-30 11:33:55 -05:00
Brues e84673dde5 item: clamp bank-bag slot count in NewItems baseline scan
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
2026-07-30 11:33:45 -05:00
Brues a2c5db7b76 time: add GetTimeCached() frame-stable timestamp
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.

Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
2026-07-30 03:12:07 -05:00
Brues 46597f6587 unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
2026-07-27 20:13:49 -05:00
Brues 1ae102b554 add string.split to workaround certain addons replacing strsplit 2026-07-27 15:41:23 -05:00
Brues a5b6cf367d aura: cache fallback for out-of-range group auras (server delta gap)
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
2026-07-26 18:19:47 -05:00
Brues b496568670 Add C_Spell.GetSpellEffectMechanics(spellID)
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
2026-07-25 12:19:51 -05:00
Brues 5f13c63c99 unit: add UnitSpellHaste(unit) -- backport of the TBC+ haste getter
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
2026-07-23 03:59:04 -05:00
Brues 39dcfc73de event: add GetFramesRegisteredForEvent(event)
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
2026-07-21 14:41:10 -05:00
Brues 6e5f7c3e43 tradeskilllink: gate detail wheel on overflow
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
2026-07-21 14:25:43 -05:00
Brues 2e05e86362 tradeskilllink: fix detail-pane scroll range + auto-hide
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.

- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
  RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
  button relative to the child top + name-plate overhang) instead of a
  hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
  rather than the engine's range-changed event: scrollBarHideable only
  auto-hides on a range CHANGE, so content that fits from the start (e.g.
  Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
  readable the same tick as UpdateScrollChildRect.

Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.

Fixes #10
2026-07-21 14:21:25 -05:00
Brues bcc631c4ed tradeskilllink: fix ColorMixin clobber from Fonts.xml re-includers
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).

Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.

Also simplify the viewer frame anchor to SetPoint("CENTER").
2026-07-21 12:54:39 -05:00
Brues f9fb32186d TradeSkillLink: Play Open/Close Sound 2026-07-21 11:11:41 -05:00
Brues 60adbba7c3 pointless fallback 2026-07-21 11:11:09 -05:00
Brues c35d883af0 targeting: backport TargetDirection*/TargetNearest* + GetPlayerFacing
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:

  TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
  TargetNearest([reverse])
  TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
  GetPlayerFacing()

All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).

Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.

GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.

Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.

New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
2026-07-21 00:49:05 -05:00
Brues c202c1000f chatbubbles: add C_ChatBubbles.GetAllChatBubbles
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().

includeForbidden is accepted and ignored (vanilla has no forbidden
frames).

Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
2026-07-20 23:00:03 -05:00
Brues 98b4ae14e7 totem: add GetTotemDuration(slot)
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
2026-07-20 21:27:58 -05:00
Brues 8d957eb424 totem: add TargetTotem(slot)
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
2026-07-20 21:25:34 -05:00
Brues 78618ea2cc totem: add GetTotemTimeLeft(slot)
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
2026-07-20 21:11:01 -05:00
Brues 1a4583e16e totem: fire PLAYER_TOTEM_UPDATE on slot changes
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
2026-07-20 21:04:15 -05:00
Brues a20e1206fb totem: add GetTotemInfo(slot) with a self-tracking totem subsystem
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:

- Slot from the summon spell's Spell.dbc effect (87-90 =
  SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
  the spell requires (a tool, not a consumed reagent) — resolved from the
  player's spellbook on SPELLS_CHANGED, not hardcoded.

Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).

GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
2026-07-20 20:53:01 -05:00
Brues 9f67cade87 unit: add UnitOwnerGUID(unit)
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
2026-07-20 18:35:47 -05:00
Brues 8629e13e1f gameobject: add ClosestGameObjectPosition(gameObjectID)
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.

Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.

Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
2026-07-20 18:26:46 -05:00
Brues ca9bfbab5e unit: add ClosestUnitPosition(creatureID)
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.

Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
2026-07-20 18:18:52 -05:00
Brues 8fdc767cc7 item: add Enum.ItemQuality
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
2026-07-20 17:36:47 -05:00
Brues aa31c25be5 item: add GetItemClassInfo, GetItemSubClassInfo global, Enum.ItemClass
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
  as both a global and C_Item.GetItemClassInfo. Obsolete class slots
  return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
  C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
  (Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
  post-vanilla 16..19 for parity, matching the Enum.InventoryType
  precedent in the same module.

All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
2026-07-20 17:27:28 -05:00
Brues 29aafe88f8 turtle: move combo-finisher & armor-healing custom data out of stock
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.

- aura/ComboDuration: add an AutoDanglingResolver extension point; the
  stock module keeps the dangling-row detection + scaling formula, and
  Turtle's reworked-Rip base/max values move to
  src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
  dangling-row condition stays the gate (gate on the bug, not the
  realm), preserving prior behavior exactly.

- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
  the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
  (xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
  src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
  is a generic slot that could collide on another server).

Behavior is unchanged on Turtle; both extractions are pure relocation.
2026-07-20 16:49:45 -05:00
Brues 461f15a4a6 item: derive bag family from (class, subclass) for bags with empty field
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.

Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.

Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
Brues 6245d72694 unit: make UnitInRange reach-aware
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
2026-07-20 14:52:35 -05:00
Brues bd14228ad7 event: add PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.

PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.

Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.

New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.

Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
2026-07-20 03:22:07 -05:00
Brues 5b15172894 coroutine: guard AuxResume xmoves with lua_checkstack
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.

Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
2026-07-20 00:48:28 -05:00
Brues b5470e8d3d lua: add string.reverse/strrev, math.modf, math.huge
Close the remaining 5.0->5.1 string/math stdlib gaps:

- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
  strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
  fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
  which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
  table (it's a value, not a function).

All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
2026-07-20 00:31:20 -05:00
Brues c601967fdb lua: add strjoin / strtrim / strreplace
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:

- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
  delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
  from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
  substring replace; empty find returns str unchanged. Not a stock WoW global
  (that build's compat script added it); documented as a ClassicAPI
  convenience complementing gsub.

Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
2026-07-20 00:21:59 -05:00
Brues 5c063ade73 lua: add strsplit; bind lua_checkstack
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.

Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
  GC step during a pushlstring could free the buffer we're still scanning.
  Leaving the args on the stack keeps the source GC-rooted; Lua returns the
  top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
  cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
  push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
  exhaustion (strsplit pushes an unbounded number of results). Reusable for
  any future multi-return C function.

Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
2026-07-20 00:05:24 -05:00
250 changed files with 18697 additions and 2730 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
+7
View File
@@ -38,3 +38,10 @@ Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\SecureUnitMenu.lua
+43 -1
View File
@@ -38,4 +38,46 @@ if C_AddOns.DoesAddOnExist('pfUI') then
end)
end
end)
end
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
local libp = ShaguTweaks.libpredict
if libp then
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
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
@@ -0,0 +1,40 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so a registered rule mirrors the server's edit off that trigger.
--
-- 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).
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
end
end)
+19 -9
View File
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
for i = 1, boundN do bound[i] = arg[i] end
return function(...)
local callN = arg.n
local merged = {}
for i = 1, boundN do merged[i] = bound[i] end
for i = 1, callN do merged[boundN + i] = arg[i] end
return func(unpack(merged, 1, boundN + callN))
-- Forward bound + call args faithfully, embedded/leading nils and all.
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
-- a `.n` field: unpack takes its count from getn (which returns t.n)
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
local all = {}
local n = 0
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
for i = 1, callN do n = n + 1; all[n] = arg[i] end
all.n = n
return func(unpack(all))
end
end
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(event, ...)
if funcs then
for owner, func in pairs(funcs) do
-- Stock 3.3.5 passes the owner as the first arg so callers can
-- distinguish multiple registrations of the same function.
local n = arg.n
local merged = { owner }
for i = 1, n do merged[i + 1] = arg[i] end
func(unpack(merged, 1, n + 1))
-- distinguish multiple registrations of the same function. `arg`
-- carries its own `.n`, so unpack(arg) forwards the payload with
-- leading/embedded nils intact. Do NOT instead merge into a
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
-- build's unpack counts via getn, so a `.n`-less table collapses at
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
-- arg1=nil arg2=1, dropped the payload).
func(owner, unpack(arg))
end
end
end
+24 -44
View File
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
return self.r, self.g, self.b
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
return h, s, l, a or 1
end
function ColorMixin:GetRGBAsBytes()
return self.r * 255, self.g * 255, self.b * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
end
function ColorMixin:GetRGBA()
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
end
function ColorMixin:GetRGBAAsBytes()
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
end
function ColorMixin:SetRGBA(r, g, b, a)
self.r = r
self.g = g
self.b = b
self.a = a
self.a = a or 1
end
function ColorMixin:SetRGB(r, g, b)
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
end
function ColorMixin:GenerateHexColor()
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
return C_ColorUtil.GenerateTextColorCode(self)
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
end
function ColorMixin:GenerateHexColorMarkup()
return "|c"..self:GenerateHexColor()
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local max, min = math.max(r, g, b), math.min(r, g, b)
local h, s, l
l = (max + min) / 2
if max == min then
h, s = 0, 0 -- achromatic
else
local d = max - min
if l > 0.5 then
s = d / (2 - max - min)
else
s = d / (max + min)
end
if max == r then
h = (g - b) / d
if g < b then
h = h + 6
end
elseif max == g then
h = (b - r) / d + 2
elseif max == b then
h = (r - g) / d + 4
end
h = h / 6
end
return h, s, l, a or 1
end
function ColorMixin:WrapTextInColorCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, self)
end
function WrapTextInColorCode(text, colorHexString)
return string.format("|c%s%s|r", colorHexString, text)
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
end
function WrapTextInColor(text, color)
return WrapTextInColorCode(text, color:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, color)
end
function ColorMixin:WrapTextInColorTableCode(text)
@@ -108,11 +85,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
do
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
+26 -13
View File
@@ -1,3 +1,8 @@
COLOR_FORMAT_RGBA = "RRGGBBAA"
COLOR_FORMAT_RGB = "RRGGBB"
COLOR_FORMAT_ARGB = "AARRGGBB"
FONT_COLOR_CODE_CLOSE = "|r"
do
local ColorMixin = ColorMixin
for _, classColor in pairs(RAID_CLASS_COLORS) do
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
end
function CreateColorFromHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
end
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
return nil;
end
function CreateColorFromRGBAHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
end
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
return nil;
end
function CreateColorFromRGBHexString(hexColor)
if string.len(hexColor) == 6 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
return CreateColor(r, g, b, 1);
else
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
end
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBestRGBHexString(hexColor)
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
return CreateColorFromRGBAHexString(hexColor);
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
return CreateColorFromRGBHexString(hexColor);
end
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBytes(r, g, b, a)
@@ -68,9 +83,7 @@ end
function GetClassColoredTextForUnit(unit, text)
local _, classFilename = UnitClass(unit);
local color = GetClassColorObj(classFilename);
if (color) then
return color:WrapTextInColorCode(text);
end
return color and color:WrapTextInColorCode(text) or text;
end
function GetFactionColor(factionGroupTag)
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
end
function RGBToColorCode(r, g, b)
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
end
function RGBTableToColorCode(rgbTable)
+16 -14
View File
@@ -2,7 +2,9 @@
--
-- Implementation differences from the modern source:
-- - Lua 5.0 has no select() and no `...` as expression; use the
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
-- args, so the count must live in the table's `.n`.
-- - Lua 5.0 has no `#` operator; use table.getn().
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
-- the script call in a local closure that captures frame + arg.
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
if script then
local args = arg;
local function invoke()
return script(frame, unpack(args, 1, args.n));
return script(frame, unpack(args));
end
xpcall(invoke, CallErrorHandler);
end
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
end
if ancestor then
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
return true, ancestor[methodName](ancestor, unpack(arg));
end
return false;
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
local value = tbl[key];
if type(value) == "function" then
return value(unpack(arg, 1, arg.n));
return value(unpack(arg));
else
return value;
end
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
end
local s_passThroughClosureGenerators = {
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
function(f) return function(...) return f(unpack(arg)); end; end,
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
};
local s_flatClosureGenerators = {
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
local count = arg.n;
local generator = generatorArray[count + 1];
if generator then
return generator(f, unpack(arg, 1, count));
return generator(f, unpack(arg));
end
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
@@ -147,14 +149,14 @@ end
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
function GenerateClosure(f, ...)
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
end
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
function GenerateFlatClosure(f, ...)
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
end
function RunNextFrame(callback)
@@ -166,7 +168,7 @@ FunctionUtil = {};
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
local method = table[methodName];
if method then
return method(table, unpack(arg, 1, arg.n));
return method(table, unpack(arg));
end
return nil;
@@ -22,6 +22,18 @@ function EventRegistry:OnLoad()
-- Lua-5.0 varargs, so we forward through a small wrapper.
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
-- Stop delivering events once logout begins: a callback firing during the
-- engine's teardown can touch state that's already gone. A dedicated frame
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
-- — folding the check into frameEventFrame's OnEvent would depend on the
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
self.logoutFrame = CreateFrame("Frame")
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents()
self.frameEventFrame:SetScript("OnEvent", nil)
end)
end
-- Helper so we can forward up to nine event args (the most any vanilla
+2 -5
View File
@@ -216,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
end
if self.itemID then
local _, link = GetItemInfo(self.itemID)
return link
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
@@ -299,9 +298,7 @@ end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
return equipLoc
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
+38 -3
View File
@@ -11,12 +11,19 @@ local random = math.random
local atan2 = math.atan2
local sqrt = math.sqrt
math.huge = 1 / 0
MathUtil =
{
Epsilon = .000001,
};
function CreateCounter(initialCount)
MathUtil.ApproxZero = MathUtil.Epsilon;
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
function CreateCounter(initialCount, step)
local count = initialCount or 0
step = step or 1
return function()
count = count + 1
count = count + step
return count
end
end
@@ -130,3 +137,31 @@ end
function CalculateAngleBetween(x1, y1, x2, y2)
return atan2(y2 - y1, x2 - x1)
end
AccumulatorMixin = {};
function AccumulatorMixin:Init(initialCount)
self.count = initialCount or 0;
end
function AccumulatorMixin:Add(count)
self.count = self.count + count;
return self.count;
end
function AccumulatorMixin:Subtract(count)
self.count = self.count - count;
return self.count;
end
function AccumulatorMixin:Count()
return self.count;
end
function AccumulatorMixin:Reset(resetCount)
self.count = resetCount or 0;
end
function CreateAccumulator(initialCount)
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
end
+7 -20
View File
@@ -1,24 +1,11 @@
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
-- even with this addon disabled, mirroring retail where they live in
-- TableUtil as C functions. Only the composite helper (which calls a Lua
-- `:Init` method) stays here, like retail's Mixin.lua.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no select(); inside a vararg function the args are
-- gathered into an implicit `arg` table (`arg.n` for the count).
function Mixin(object, ...)
for i = 1, arg.n do
local mixin = arg[i]
if mixin then
for k, v in pairs(mixin) do
object[k] = v
end
end
end
return object
end
function CreateFromMixins(...)
return Mixin({}, unpack(arg))
end
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
-- so we forward them with unpack(arg) rather than 5.1's `...`.
function CreateAndInitFromMixin(mixin, ...)
local object = CreateFromMixins(mixin)
+1 -1
View File
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
end
function PlayerUtil.GetCurrentSpeed()
return GetUnitSpeed("player") / 7 * 100
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
end
function GetPlayerGuid()
+6 -5
View File
@@ -14,7 +14,8 @@
-- consumer addons doesn't fail; they'll error at Acquire() time
-- if anything actually invokes them.
-- - Lua 5.0 has no `...` as expression; the converter mixins use
-- `unpack(arg, 1, arg.n)` to forward varargs.
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
-- fallbacks (same pattern as FunctionUtil.lua).
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
end
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
+141
View File
@@ -0,0 +1,141 @@
RectangleMixin = {};
function CreateRectangle(left, right, top, bottom)
local rectangle = CreateFromMixins(RectangleMixin);
rectangle:OnLoad(left, right, top, bottom);
return rectangle;
end
function RectangleMixin:OnLoad(left, right, top, bottom)
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
end
function RectangleMixin:SetSides(left, right, top, bottom)
self.left = left;
self.right = right;
self.top = top;
self.bottom = bottom;
end
function RectangleMixin:Reset()
self.left = 0.0;
self.right = 0.0;
self.top = 0.0;
self.bottom = 0.0;
end
function RectangleMixin:Stretch(x, y)
self:Adjust(-x, x, -y, y);
end
function RectangleMixin:Move(x, y)
self:Adjust(x, x, y, y);
end
function RectangleMixin:Adjust(left, right, top, bottom)
self.left = self.left + left;
self.right = self.right + right;
self.top = self.top + top;
self.bottom = self.bottom + bottom;
end
function RectangleMixin:IsEmpty()
return self.left == self.right or self.top == self.bottom;
end
function RectangleMixin:IsInsideOut()
return self.left > self.right or self.top > self.bottom;
end
function RectangleMixin:EnclosesPoint(x, y)
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
end
function RectangleMixin:EnclosesRect(otherRect)
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
end
function RectangleMixin:IntersectsRect(otherRect)
return not (
self.left > otherRect.right or
self.right < otherRect.left or
self.top > otherRect.bottom or
self.bottom < otherRect.top
);
end
function RectangleMixin:GetTop()
return self.top;
end
function RectangleMixin:GetBottom()
return self.bottom;
end
function RectangleMixin:GetLeft()
return self.left;
end
function RectangleMixin:GetRight()
return self.right;
end
function RectangleMixin:GetWidth()
return self.right - self.left;
end
function RectangleMixin:GetHeight()
return self.bottom - self.top;
end
function RectangleMixin:GetCenter()
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:GetCenterH()
return Lerp(self.left, self.right, .5);
end
function RectangleMixin:GetCenterV()
return Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:SetTop(top)
self.top = top;
end
function RectangleMixin:SetBottom(bottom)
self.bottom = bottom;
end
function RectangleMixin:SetLeft(left)
self.left = left;
end
function RectangleMixin:SetRight(right)
self.right = right;
end
function RectangleMixin:SetWidth(width)
self.right = self.left + width;
end
function RectangleMixin:SetHeight(height)
self.bottom = self.top + height;
end
function RectangleMixin:SetSize(width, height)
self:SetWidth(width);
self:SetHeight(height);
end
function RectangleMixin:SetCenter(x, y)
local width = self:GetWidth();
local height = self:GetHeight();
self.left = x - width * .5;
self.right = x + width * .5;
self.top = y - height * .5;
self.bottom = y + height * .5;
end
@@ -0,0 +1,125 @@
-- Unit right-click menu for attribute-driven unit frames.
--
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
-- attribute resolves a click to the `menu` verb, the DLL calls
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
-- 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. 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 (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");
dropdown.displayMode = "MENU";
end
return dropdown;
end
local function ResolveMenu(unit)
if UnitIsUnit(unit, "player") then
return "SELF";
elseif UnitIsUnit(unit, "pet") then
return "PET";
elseif UnitIsPlayer(unit) then
if UnitInParty(unit) or UnitInRaid(unit) then
return "PARTY";
end
return "PLAYER";
end
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 dd = EnsureDropdown();
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
+1 -1
View File
@@ -431,7 +431,7 @@ function SafePack(...)
end
function SafeUnpack(tbl)
return unpack(tbl, 1, tbl.n)
return unpack(tbl)
end
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
+67 -65
View File
@@ -248,7 +248,7 @@ end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
@@ -259,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
@@ -296,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
@@ -461,8 +448,8 @@ local function CreateFrame_TradeSkillLink()
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
if this.itemID then
GameTooltip:SetHyperlink("item:"..this.itemID);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
@@ -503,29 +490,36 @@ local function CreateFrame_TradeSkillLink()
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
if this.itemID then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:SetHyperlink("item:"..this.itemID);
GameTooltip:Show();
end
end);
btn:SetScript("OnClick", function()
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(this.itemLink);
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
@@ -571,19 +565,22 @@ local function CreateFrame_TradeSkillLink()
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
local name, tex, link = itemInfo(r.itemID);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
btn.itemID = reagent.itemID
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
@@ -591,32 +588,36 @@ local function CreateFrame_TradeSkillLink()
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
@@ -636,11 +637,12 @@ local function CreateFrame_TradeSkillLink()
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
@@ -732,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
+3
View File
@@ -0,0 +1,3 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
end
+142
View File
@@ -0,0 +1,142 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local sqrt = math.sqrt;
function Vector2D_ScaleBy(scalar, x, y)
return x * scalar, y * scalar;
end
function Vector2D_DivideBy(divisor, x, y)
return x / divisor, y / divisor;
end
function Vector2D_Add(leftX, leftY, rightX, rightY)
return leftX + rightX, leftY + rightY;
end
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
return leftX - rightX, leftY - rightY;
end
function Vector2D_Cross(leftX, leftY, rightX, rightY)
return leftX * rightY - leftY * rightX;
end
function Vector2D_Dot(leftX, leftY, rightX, rightY)
return leftX * rightX + leftY * rightY;
end
function Vector2D_GetLengthSquared(x, y)
return Vector2D_Dot(x, y, x, y);
end
function Vector2D_GetLength(x, y)
return sqrt(Vector2D_GetLengthSquared(x, y));
end
function Vector2D_Normalize(x, y)
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
end
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
end
function Vector2D_RotateDirection(rotationRadians, x, y)
local cosValue = cos(rotationRadians);
local sinValue = sin(rotationRadians);
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
end
Vector2DMixin = {};
function CreateVector2D(x, y)
local vector = CreateFromMixins(Vector2DMixin);
vector:OnLoad(x, y);
return vector;
end
function AreVector2DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector2DMixin:OnLoad(x, y)
self:SetXY(x, y);
end
function Vector2DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y;
end
function Vector2DMixin:GetXY()
return self.x, self.y;
end
function Vector2DMixin:SetXY(x, y)
self.x = x;
self.y = y;
end
function Vector2DMixin:ScaleBy(scalar)
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:DivideBy(scalar)
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:Add(other)
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Subtract(other)
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Cross(other)
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Dot(other)
return Vector2D_Dot(self.x, self.y, other:GetXY());
end
function Vector2DMixin:IsZero()
return self.x == 0 and self.y == 0;
end
function Vector2DMixin:GetLengthSquared()
return Vector2D_GetLengthSquared(self:GetXY());
end
function Vector2DMixin:GetLength()
return Vector2D_GetLength(self:GetXY());
end
function Vector2DMixin:Normalize()
self:SetXY(Vector2D_Normalize(self:GetXY()));
return self;
end
function Vector2DMixin:RotateDirection(rotationRadians)
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
return self;
end
function Vector2DMixin:Clone()
return CreateVector2D(self:GetXY());
end
+173
View File
@@ -0,0 +1,173 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector3D_ScaleBy(scalar, x, y, z)
return x * scalar, y * scalar, z * scalar;
end
function Vector3D_DivideBy(divisor, x, y, z)
return x / divisor, y / divisor, z / divisor;
end
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX + rightX, leftY + rightY, leftZ + rightZ;
end
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX - rightX, leftY - rightY, leftZ - rightZ;
end
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
end
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX * rightX + leftY * rightY + leftZ * rightZ;
end
function Vector3D_GetLengthSquared(x, y, z)
return Vector3D_Dot(x, y, z, x, y, z);
end
function Vector3D_GetLength(x, y, z)
return sqrt(Vector3D_GetLengthSquared(x, y, z));
end
function Vector3D_Normalize(x, y, z)
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
end
function Vector3D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector3D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector3D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector3D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
return cos(-pitch) * cos(yaw),
cos(-pitch) * sin(yaw),
sin(-pitch);
end
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
if x ~= 0 or y ~= 0 then
return atan2(y, x), asin(-z);
end
return 0, asin(-z);
end
function Vector3D_CalculateYawPitchFromNormalVector(vector)
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
end
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
end
Vector3DMixin = {};
function CreateVector3D(x, y, z)
local vector = CreateFromMixins(Vector3DMixin);
vector:OnLoad(x, y, z);
return vector;
end
function AreVector3DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector3DMixin:OnLoad(x, y, z)
self:SetXYZ(x, y, z);
end
function Vector3DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z;
end
function Vector3DMixin:GetXYZ()
return self.x, self.y, self.z;
end
function Vector3DMixin:SetXYZ(x, y, z)
self.x = x;
self.y = y;
self.z = z;
end
function Vector3DMixin:ScaleBy(scalar)
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:DivideBy(scalar)
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:Add(other)
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Subtract(other)
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Cross(other)
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Dot(other)
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
end
function Vector3DMixin:GetLengthSquared()
return Vector3D_GetLengthSquared(self:GetXYZ());
end
function Vector3DMixin:GetLength()
return Vector3D_GetLength(self:GetXYZ());
end
function Vector3DMixin:Normalize()
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
return self;
end
function Vector3DMixin:Clone()
return CreateVector3D(self:GetXYZ());
end
+143
View File
@@ -0,0 +1,143 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector4D_ScaleBy(scalar, x, y, z, w)
return x * scalar, y * scalar, z * scalar, w * scalar;
end
function Vector4D_DivideBy(divisor, x, y, z, w)
return x / divisor, y / divisor, z / divisor, w / divisor;
end
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
end
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
end
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
end
function Vector4D_GetLengthSquared(x, y, z, w)
return Vector4D_Dot(x, y, z, w, x, y, z, w);
end
function Vector4D_GetLength(x, y, z, w)
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
end
function Vector4D_Normalize(x, y, z, w)
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
end
function Vector4D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector4D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector4D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector4D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
Vector4DMixin = {};
function CreateVector4D(x, y, z, w)
local vector = CreateFromMixins(Vector4DMixin);
vector:OnLoad(x, y, z, w);
return vector;
end
function AreVector4DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector4DMixin:OnLoad(x, y, z, w)
self:SetXYZW(x, y, z, w);
end
function Vector4DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z
and self.w == otherVector.w;
end
function Vector4DMixin:GetXYZW()
return self.x, self.y, self.z, self.w;
end
function Vector4DMixin:SetXYZW(x, y, z, w)
self.x = x;
self.y = y;
self.z = z;
self.w = w;
end
function Vector4DMixin:ScaleBy(scalar)
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:DivideBy(scalar)
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:Add(other)
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Subtract(other)
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Dot(other)
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
end
function Vector4DMixin:GetLengthSquared()
return Vector4D_GetLengthSquared(self:GetXYZW());
end
function Vector4DMixin:GetLength()
return Vector4D_GetLength(self:GetXYZW());
end
function Vector4DMixin:Normalize()
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
return self;
end
function Vector4DMixin:Clone()
return CreateVector4D(self:GetXYZW());
end
+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 = "/清除專注目標";
+4 -23
View File
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
end
end);
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
local function DebugTools_IsMouseOver(frame)
if (not frame) then return false; end
local visOK, visible = pcall(frame.IsVisible, frame);
if (not visOK or not visible) then return false; end
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
if (not sOK or not scale or scale == 0) then return false; end
local cx, cy = GetCursorPosition();
cx = cx / scale;
cy = cy / scale;
local lOK, left = pcall(frame.GetLeft, frame);
local rOK, right = pcall(frame.GetRight, frame);
local tOK, top = pcall(frame.GetTop, frame);
local bOK, bottom = pcall(frame.GetBottom, frame);
if (not (lOK and rOK and tOK and bOK)) then return false; end
if (not (left and right and top and bottom)) then return false; end
return cx >= left and cx <= right and cy >= bottom and cy <= top;
end
local _EventTraceFrame;
_framesSinceLast = 0;
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
button:UnlockHighlight();
end
if (DebugTools_IsMouseOver(button)) then
if MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local mouseOver = DebugTools_IsMouseOver(frame);
if (mouseOver and (showHidden or (visOK and visible))) then
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
local nameOK, name = pcall(frame.GetName, frame);
if (not nameOK or not name) then name = "<anonymous>"; end
table.insert(matches, { name = name, frame = frame });
@@ -1037,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
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+46 -29
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})
@@ -118,7 +128,9 @@ target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
# CopyToClipboard in src/clipboard/Copy.cpp.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
@@ -132,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"
)
+65 -40
View File
@@ -21,7 +21,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
@@ -29,24 +31,25 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [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` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [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) | `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` |
@@ -56,17 +59,19 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.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.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.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` |
@@ -86,6 +91,7 @@ the glue state because GlueXML had no way to reach them otherwise.
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
@@ -144,10 +150,24 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
@@ -166,6 +186,8 @@ when launching with `-console`), not as Lua functions. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
| 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) |
### Unit tokens
@@ -173,6 +195,7 @@ when launching with `-console`), not as Lua functions. See the
|-------|-------------|
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
@@ -216,36 +239,38 @@ The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
`MergeTable`, `SafePack`, etc.), and `EventUtil`
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` etc.).
Some of the addon's surface depends on engine functions ClassicAPI
provides (`C_UIColor.GetColors` populates the modern color globals,
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
is pure Lua and would work standalone. Without the DLL the addon
still loads, but those features no-op.
The triple-`!` prefix is **load-order-significant** — WoW loads
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
any consumer that depends on its globals. Don't rename the folder.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
`## Dependencies: !!!ClassicAPI` all work as usual.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
**It is intentionally always-on and cannot be disabled.** The library
provides FrameXML-compat fixes that other addons and the DLL's own
features rely on, so leaving it toggleable would let a user (or a
one-off character-select uncheck) silently break them. The DLL
therefore hides it from the character-select AddOns list and
force-enables it every login — so it never appears as a checkbox and
can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
If you drop the folder into your `Interface/AddOns/` directory, **that
copy's files win** — the DLL's embedded version only serves files when
the engine's normal scan doesn't already have a newer entry under that
name. Useful for editing the Lua locally without rebuilding the DLL.
The hidden + force-enabled treatment applies either way (it's keyed to
the addon name, not to which copy serves the files), so the local copy
is also absent from the AddOns list. The dispatch is transparent:
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
identically in both cases.
## Bundled addon: DebugTools
+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")
+2416 -158
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+29 -1
View File
@@ -57,7 +57,8 @@ namespace Lua {
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
@@ -184,6 +185,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
RawSet(L, GLOBALS_INDEX);
}
bool PushGlobalFunction(void *L, const char *name) {
PushString(L, name);
GetTable(L, GLOBALS_INDEX);
if (Type(L, -1) == TYPE_FUNCTION)
return true;
SetTop(L, -2); // pop the non-function
return false;
}
void CallGlobal(void *L, const char *name) {
const int top = GetTop(L);
if (PushGlobalFunction(L, name))
Call(L, 0, 0);
SetTop(L, top);
}
bool CallGlobalString(void *L, const char *name, const char *arg) {
const int top = GetTop(L);
const bool called = PushGlobalFunction(L, name);
if (called) {
PushString(L, arg);
Call(L, 1, 0);
}
SetTop(L, top);
return called;
}
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
PushString(L, globalName);
GetTable(L, GLOBALS_INDEX);
+18
View File
@@ -100,6 +100,7 @@ using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -142,6 +143,7 @@ extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
extern const lua_checkstack_t CheckStack;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
@@ -242,6 +244,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
// using `PushLocalizedString` + manual `string.format` invocation.
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
// Pushes `_G[name]` and returns true only if it resolved to a function
// (leaving it on the stack, ready to be called); otherwise pops it and
// returns false. The building block for the CallGlobal* helpers below and for
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
// several modules otherwise open-code).
bool PushGlobalFunction(void *L, const char *name);
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
// Stack-neutral.
void CallGlobal(void *L, const char *name);
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
// the global was a function and got called, so callers can fall back to a
// native path when the FrameXML/addon global is absent. Stack-neutral.
bool CallGlobalString(void *L, const char *name, const char *arg);
} // namespace Lua
// Developer-console command system — the `~` console available when the
+731 -34
View File
File diff suppressed because it is too large Load Diff
+106 -12
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);
@@ -336,7 +378,32 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
void MoveEmbeddedToListHead() {
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
// `FUN_0051da70` walks the linked list and copies an entry into
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
// setting it hides `!!!ClassicAPI` from the character-select AddOns
// list — there's no checkbox, so it can't be toggled off. The builder
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
// 0`); our entry is non-secure, so a manual `1` here is stable across
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
// never reads `+0x29`) and does NOT break dependency resolution or
// by-name queries (those use the addon hash table, which keeps the
// entry regardless).
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
// when the player has no per-character override. Belt-and-suspenders
// alongside the resolver co-hook below (which forces enabled anyway).
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// Post-registration finalize for the embedded entry: move it to the
// head of the load-order list (so consumers that read our globals from
// their main chunk see them — see the block comment above the offsets),
// hide it from the addon list, and mark it default-enabled. Combined
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
@@ -344,6 +411,11 @@ void MoveEmbeddedToListHead() {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
// Hide from the character-select AddOns list and default it
// to enabled.
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
@@ -361,7 +433,24 @@ void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
FinalizeEmbeddedEntry();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
// or any stale WTF disable-override, so hiding it from the addon list
// can never leave it stuck off. Returns 2 ("enabled for all"); the
// load-pass gate only tests `!= 0`. Every other addon falls through to
// the original untouched. See the Offsets.h note for the ABI.
using AddonEnableResolve_t =
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
char resolveAll) {
if (name != nullptr && PathEqualsCI(name, kAddonName))
return 2;
return AddonEnableResolve_o(name, account, resolveAll);
}
const Game::HookAutoRegister _hookFileRead{
@@ -374,6 +463,11 @@ const Game::HookAutoRegister _hookAddonInit{
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
const Game::HookAutoRegister _hookEnableResolve{
Offsets::FUN_ADDON_ENABLE_RESOLVE,
reinterpret_cast<void *>(&AddonEnableResolve_h),
reinterpret_cast<void **>(&AddonEnableResolve_o)};
} // namespace
} // namespace Addons::Embedded
+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 *>(
+161 -69
View File
@@ -17,18 +17,27 @@
// 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"
#include "Game.h"
#include "Offsets.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -47,24 +56,84 @@ const uint8_t *ResolveUnit(const char *token) {
return static_cast<const uint8_t *>(fn(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;
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
// return means the token was well-formed (the resolver never raised). This runs
// the same non-raising resolver and returns 0 for a token that maps to no
// rostered member — which the group-array push paths treat as "no auras".
uint64_t GuidForOutOfRange(const char *token) {
if (token == nullptr)
return 0;
return Unit::Identity::GuidForToken(token);
}
// 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;
// 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;
}
// 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) {
@@ -89,18 +158,27 @@ 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);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, match);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, nearby party range fluctuation) may still be live in
// the Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, 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, match))
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, party range fluctuation) may still be live in the
// Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
Game::Lua::PushNil(L);
return 1;
}
@@ -113,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) {
@@ -146,19 +224,23 @@ 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 int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
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, 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,
pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -171,19 +253,21 @@ 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 int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
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, pf.ToMatch());
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
spellName, fp, pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -207,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;
@@ -217,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) {
@@ -234,22 +319,29 @@ int __fastcall Script_GetUnitAuras(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (unit == nullptr)
return 1;
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
// 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 (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
if (unit != nullptr) {
if (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 (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;
}
+66 -59
View File
@@ -21,6 +21,7 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Mod.h"
#include "unit/Identity.h"
@@ -56,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
@@ -86,28 +111,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
// SpellGo time is too late. We watch sends through the shared
// `Net::SendObserver` rather than owning the funnel hook ourselves.
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
return;
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
RememberCapture(spellId, CurrentComboPoints(), NowMs());
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
@@ -132,30 +146,19 @@ const Override *FindOverride(uint32_t spellId) {
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
// Resolver chain for dangling-duration-row spells (client DBC missing the
// row). The KNOWN VALUES themselves are server-custom data and live in
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
// reworked Rip); this stock module only owns the mechanism and the
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
AutoDanglingResolver *g_danglingResolvers = nullptr;
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
r = r->next)
if (r->fn(spellId, baseMs, maxMs))
return true;
return false;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
@@ -203,6 +206,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
: fn(f), next(g_danglingResolvers) {
g_danglingResolvers = this;
}
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
@@ -227,12 +235,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
// spell. Defer to a registered resolver (the known values are
// server-custom data owned by a platform module, e.g.
// src/turtle for Turtle's reworked Rip).
if (!ResolveDangling(spellId, &baseMs, &maxMs))
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
+18
View File
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
// Extension point for finishers whose SpellDuration row DANGLES — a
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
// condition (after any Lua-registered override), so the activation gate is
// the client-data bug itself, not a server fingerprint — the deliberate
// design choice (a realm-sniffing approach was rejected). Server-custom
// data (e.g. Turtle's reworked Rip) registers its values here instead of
// living in this stock module: declare a file-scope
// `static const Aura::ComboDuration::AutoDanglingResolver`.
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
int32_t *maxMs);
struct AutoDanglingResolver {
explicit AutoDanglingResolver(DanglingResolver fn);
DanglingResolver fn;
AutoDanglingResolver *next;
};
} // namespace Aura::ComboDuration
+444 -76
View File
@@ -17,7 +17,10 @@
#include "Offsets.h"
#include "aura/Source.h"
#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>
@@ -27,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;
@@ -71,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 *);
@@ -139,6 +135,22 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// Report "no timer" once the stored expiration has elapsed — this is not a
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
// expiration table and returns:
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
// return expiration[slot] - now; // else remaining
// Permanent auras (paladin auras, passives, racials) carry an infinite
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
// cast-time in this slot for them, so it's always "already elapsed" and the
// engine shows no countdown. The signed `(int)` compare also absorbs the
// ~24.86-day GetTickCount wrap (both operands wrap together).
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
return 0.0; // expiration reached/passed → permanent or no timer
return static_cast<double>(expirationMs) * 0.001;
}
@@ -192,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)
@@ -200,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) {
@@ -233,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)
@@ -255,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;
@@ -264,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)
@@ -278,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;
}
@@ -286,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)
@@ -301,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;
}
@@ -419,56 +513,65 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// yields nil, so the table doesn't need an explicit entry.
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
// unit's auras except the local player's — for everyone else (target,
// party, raid, mouseover) `expirationTime` stays 0 unless the
// `Aura::Source` cache observed the cast. For the player we read the
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
// returns), which gives a real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
// (the *base* value); talent / glyph duration-extension modifiers are
// already baked into the expiration timestamp on the caster's side, so
// `expirationTime - GetTime()` reflects true remaining time even when the
// base `duration` doesn't include the talent boost.
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
// (stacks, level, is-this-the-player), the others pass their known-or-default
// values.
//
// Timing rules (identical across paths): `duration` is the Spell.dbc →
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID).
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications, int unitLevel,
bool isPlayer) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
if (isPlayer && spellID != 0) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
// cast time (the descriptor has neither). The player-buff table above is
// authoritative for the player's own expiration; the cache fills it for
// everyone else. Prefer the applied (caster-modified) duration so it stays
// consistent with `expirationTime` — otherwise a talented DoT (Improved
// Shadow Word: Pain) would show remaining time exceeding the base.
if (spellID != 0) {
if (spellID != 0 && guid != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
// 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);
}
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
expirationTime, casterGuid);
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer());
}
namespace {
@@ -479,17 +582,12 @@ namespace {
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
double duration = 0.0;
if (c.durationMs != 0) {
duration = static_cast<double>(c.durationMs) * 0.001;
} else {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
}
const double expirationTime =
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
c.casterGuid);
// `PushEnriched` re-reads the same cache entry `c` came from (by
// guid+spellID) to fill caster / expiration / applied duration, so the
// result is identical to reading `c`'s fields directly. Stacks aren't in
// SMSG_SPELL_GO, so `applications` is 1.
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
@@ -536,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)
@@ -549,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;
}
@@ -559,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;
}
@@ -581,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
@@ -590,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);
@@ -601,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);
@@ -612,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
@@ -625,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);
@@ -633,4 +731,274 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
}
}
// ── Out-of-range groupmate path ────────────────────────────────────────────
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
}
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return 0;
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `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,
Match match) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return false;
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
return false;
return true;
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
// roster lookup; only hit on the out-of-range path.
int GroupMemberLevel(uint64_t guid) {
return Group::MemberStats::Lookup(guid).level;
}
// True if the group array exposes ANY visible aura (either kind). When it
// does, the server's out-of-range aura data is present and authoritative, so
// we do NOT supplement from the Aura::Source cache — mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only when the array is entirely empty do we fall back to the cache: the
// server resends out-of-range stats as deltas only, so a member seen in range
// and then moved away again leaves an empty party-stats aura array (the array
// is cleared when they become visible and never re-sent), yet their auras
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
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 (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
int oneBasedIndex, Filter filter, 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], match))
continue;
if (++seen == oneBasedIndex) {
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
GroupMemberLevel(guid), false);
return true;
}
}
return false;
}
// Append every cache-fallback aura of `filter`'s kind into the array table.
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
Filter filter, 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], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
false);
Game::Lua::SetTable(L, outerIdx);
}
}
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, Match match) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
if (spellName != nullptr) {
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false);
return true;
}
return false;
}
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
Match match) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
false);
return true;
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
match);
return false;
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, Match match) {
if (guid == 0 || spellID == 0)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
match))
return true;
}
return false;
}
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, Match match) {
if (guid == 0 || spellName == nullptr || *spellName == '\0')
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0)
continue;
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
match))
return true;
}
return false;
}
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey) {
if (guid == 0)
return;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, level, false);
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
nextKey);
}
} // namespace Aura::Data
+91 -5
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,6 +175,47 @@ 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 ────────────────────────────────────────────
//
// When a party/raid member has no live CGUnit at all (different map, far out
// of range) the token resolves to a GUID but no object, so there is no
// descriptor to read. The server still transmits that member's current aura
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
// out of range — and build the same AuraData table.
//
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
// no results. These are only meaningful when the unit has NO descriptor —
// callers use them in the `unit == nullptr` branch, so there is no descriptor
// to dedup against.
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
// matching `filter`. On a hit pushes the table and returns true; otherwise
// pushes nothing and returns false (caller pushes nil).
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
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, 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, 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, Match match,
int outerIdx, int &nextKey);
} // namespace Aura::Data
+626 -41
View File
@@ -19,13 +19,18 @@
#include "Data.h"
#include "Game.h"
#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"
#include "tick/WorldTick.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace Aura::Source {
@@ -69,6 +74,49 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- Recent local-player aura casts --------------------------------------
// The aura-application hooks (OnAuraAdded / OnAuraStacksChanged) get no caster,
// so they compute the UNMODIFIED base duration (`skipMod = 1`). That's correct
// for other units' auras, but wrong for one the LOCAL PLAYER just cast: it
// should carry the player-talented duration (Improved Shadow Word: Pain → 24s
// vs the 18s base). SpellGo normally owns that entry with the talented value,
// but when it can't attribute the cast to this target — an empty SMSG_SPELL_GO
// hit list files it under the caster instead, or a slot reshuffle evicts and
// refills the entry after SpellGo ran — the base value is what's left, so the
// duration flickers 24 → 18. SpellGo (which knows caster == player) records the
// spellId here so the application hooks can recover the player-modified
// duration for it.
constexpr int kRecentCastCount = 16;
constexpr uint32_t kRecentCastTtlMs = 1500;
struct RecentCast {
uint32_t spellId;
uint32_t tMs; // 0 = empty
};
RecentCast g_recentCasts[kRecentCastCount];
int g_recentCastCursor = 0;
void RememberPlayerCast(uint32_t spellId) {
const uint32_t now = NowMs();
for (auto &r : g_recentCasts)
if (r.spellId == spellId) {
r.tMs = now;
return;
}
g_recentCasts[g_recentCastCursor] = {spellId, now};
g_recentCastCursor = (g_recentCastCursor + 1) % kRecentCastCount;
}
bool WasRecentPlayerCast(uint32_t spellId) {
if (spellId == 0)
return false;
const uint32_t now = NowMs();
for (const auto &r : g_recentCasts)
if (r.spellId == spellId && r.tMs != 0 && now - r.tMs <= kRecentCastTtlMs)
return true;
return false;
}
// ---- Cache ---------------------------------------------------------------
// Helpful/harmful classification, recorded from the aura's descriptor slot
@@ -83,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,
@@ -102,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.
@@ -119,19 +221,83 @@ 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 ---------------------------
//
// Transition state for `ObserveGroupAuras`: the last aura spell-ID set we saw
// for each out-of-range group member, so we can stamp a duration guess only
// when an aura genuinely appears (not for auras already present when we first
// started watching that member). See the header for the rationale.
struct GroupSnapshot {
uint64_t guid;
uint32_t touchMs;
bool used;
uint16_t ids[Offsets::UNIT_AURA_TOTAL];
};
constexpr int kGroupSnapshots = 44; // MAX_RAID (40) + slack
constexpr uint32_t kGroupSnapshotTtlMs = 30000; // forget members not polled for 30s
GroupSnapshot g_groupSnaps[kGroupSnapshots];
bool SnapshotHasId(const GroupSnapshot &s, uint16_t id) {
for (uint16_t v : s.ids)
if (v == id)
return true;
return false;
}
// Stamp a base-duration expiration guess for a group aura that just appeared.
// casterGuid stays 0 (unknown), so `Store`'s guard preserves any real SpellGo
// timing we already hold. Skips non-aura spells and auras with no finite base
// duration (nothing meaningful to guess — leave expiration unknown).
void StampGroupGuess(uint64_t guid, uint16_t spellId, int8_t kind, uint32_t now) {
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ false);
if (base == 0)
return;
Store(guid, spellId, /*casterGuid*/ 0, now + base, base, /*fromCast*/ false,
kind);
}
// Evict the entry for an aura the engine reports gone. Keyed by (target,
@@ -151,10 +317,275 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
//
// Some server mechanics change a DoT's remaining time on another unit with
// NO client-visible signal — verified in tortoise-wow: the change goes
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules 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 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
// roll outcome, so inferring them would show wrong timers.
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
struct DurationMod {
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
uint32_t affectedFamily; // SpellFamilyName of the affected aura
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
int32_t op;
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
};
constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
// 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 (icon != 0 &&
*reinterpret_cast<const uint32_t *>(
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
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
if (m.triggerSpellId != 0)
return m.triggerSpellId == triggerSpellId;
if (triggerRec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
return false;
if (m.triggerSchool >= 0 &&
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
return false;
return true;
}
// 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) {
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)) {
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
SignalAuraChanged(e.targetGuid);
} else {
e.used = false; // shaved to/past now → server removes it
}
}
break;
case MOD_REMOVE:
e.used = false; // removal — OnAuraRemoved / descriptor path fires UNIT_AURA
break;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
// own, so they'd otherwise be dropped.
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
const uint64_t *targets, int numTargets) {
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
return;
const uint8_t *triggerRec =
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
const uint32_t now = NowMs();
for (int r = 0; r < g_modCount; ++r) {
const DurationMod &m = g_mods[r];
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
// but PROC-applied ones (Shadow Weaving) enter the cache via the
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
// caster), so accept those too and attribute them to this trigger
// caster (also gives them a sourceUnit).
if (e.casterGuid != 0 && e.casterGuid != caster)
continue;
if (!AffectedMatches(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
continue;
if (e.casterGuid == 0)
e.casterGuid = caster;
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
}
}
}
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
DurationMod &m = g_mods[i];
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
m.op = op;
m.valueMs = valueMs;
return true;
}
}
if (g_modCount >= kMaxMods)
return false;
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
affectedFamily, affectedMask, affectedIcon, op, valueMs};
return true;
}
int OpFromString(const char *s) {
if (s == nullptr)
return -1;
if (_stricmp(s, "refresh") == 0)
return MOD_REFRESH;
if (_stricmp(s, "reduce") == 0)
return MOD_REDUCE;
if (_stricmp(s, "set") == 0)
return MOD_SET;
if (_stricmp(s, "remove") == 0)
return MOD_REMOVE;
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
// by SpellFamilyName + school index instead of an exact spellID — one rule
// covers every rank / server-added spell of a class's school. triggerSchool
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
// refresh the target's Shadow Vulnerability.
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
"affectedIcon, op[, valueSeconds])");
return 0;
}
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
const int op = OpFromString(Game::Lua::ToString(L, 6));
const int32_t valueMs =
Game::Lua::IsNumber(L, 7)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
: 0;
if (op < 0 || tfamily == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
family, mask, icon, op, valueMs));
return 1;
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
// Reset transition baselines too: post-transition group auras re-sync and
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
s.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
@@ -187,9 +618,28 @@ 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.
for (auto &s : g_groupSnaps) {
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};
@@ -203,33 +653,34 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// keeps the parse short and robust.
constexpr int kMaxTargets = 16;
using SpellGo_t = void(__fastcall *)(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet);
SpellGo_t g_origSpellGo = nullptr;
void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet) {
uint64_t targets[kMaxTargets];
int numTargets = 0;
if (packet != nullptr) {
const uint32_t savedRead = packet->m_read;
Net::Read<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = Net::Read<uint8_t>(packet);
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = Net::Read<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
packet->m_read = savedRead; // restore so the engine re-parses cleanly
}
g_origSpellGo(itemGUID, casterGUID, spellId, packet);
const uint64_t caster = (casterGUID != nullptr) ? *casterGUID : 0;
// The self-contained SPELL_GO processing (succeeded events, server-side
// duration edits, aura-source caching). None of it reads engine descriptor
// state, so it's order-independent of the engine's own SPELL_GO handler —
// which is why it can run from the dispatch funnel (before the leaf handler)
// rather than after a co-hook's original call. The aura *application* hooks
// fire off a separate SMSG_UPDATE_OBJECT, so RememberPlayerCast's handoff to
// them is unaffected by the move.
void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
int numTargets) {
if (caster == 0 || spellId == 0)
return;
// Feed the totem tracker + fire UNIT_SPELLCAST_SUCCEEDED BEFORE the aura
// gate below — a totem summon (and any non-aura spell) applies no aura,
// so it would otherwise be dropped. SPELL_GO is "the spell went off", so
// this is the succeeded signal for instants too. Player casts only.
if (caster == Unit::Identity::PlayerGuid()) {
Totem::Tracker::OnPlayerSpellGo(spellId);
Spell::CastEvents::OnPlayerSucceeded(static_cast<int>(spellId));
} else {
Spell::CastEvents::OnRemoteSucceeded(caster, static_cast<int>(spellId));
}
// Mirror server-side duration edits the client is never told about
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
@@ -246,6 +697,12 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
// Let the application hooks recover the player-talented duration for this
// spell if they end up owning the target's entry (empty hit list / refill
// race) — see WasRecentPlayerCast.
if (casterIsPlayer)
RememberPlayerCast(spellId);
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true,
@@ -257,9 +714,33 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
KIND_UNKNOWN);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
reinterpret_cast<void *>(&SpellGo_h),
reinterpret_cast<void **>(&g_origSpellGo)};
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
// pre-decoded itemGuid/casterGuid/spellId; here we're at the raw body, so we
// decode them ourselves before the hit list:
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(i16),
// numHit(u8), hitGuids(u64 × numHit).
void ParseSpellGo(CDataStore *packet) {
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const uint32_t spellId = Net::Read<uint32_t>(packet);
Net::Read<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = Net::Read<uint8_t>(packet);
uint64_t targets[kMaxTargets];
int numTargets = 0;
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = Net::Read<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
HandleSpellGo(caster, spellId, targets, numTargets);
}
void SpellGoSub(uint32_t opcode, CDataStore *packet) {
if (opcode == Offsets::SMSG_SPELL_GO && packet != nullptr)
ParseSpellGo(packet);
}
const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
@@ -277,9 +758,15 @@ void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
if (unitGuid == 0)
return;
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
// If the local player just cast this aura, use its player-talented duration
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
Store(unitGuid, spellId, caster, expirationMs, durationMs,
/*fromCast*/ false, kind);
}
@@ -382,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) {
@@ -417,4 +951,55 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
return n;
}
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
if (guid == 0 || auraArray == nullptr)
return;
const int total = Offsets::UNIT_AURA_TOTAL;
const int buffCount = Offsets::UNIT_AURA_BUFF_COUNT;
const uint32_t now = NowMs();
GroupSnapshot *snap = nullptr;
for (auto &s : g_groupSnaps)
if (s.used && s.guid == guid) {
snap = &s;
break;
}
if (snap == nullptr) {
// First sight of this member — record a baseline without stamping;
// their current auras have unknown age.
GroupSnapshot *dst = nullptr;
for (auto &s : g_groupSnaps)
if (!s.used) {
dst = &s;
break;
}
if (dst == nullptr) { // full — evict the least-recently-polled
dst = &g_groupSnaps[0];
for (auto &s : g_groupSnaps)
if (s.touchMs < dst->touchMs)
dst = &s;
}
dst->guid = guid;
dst->used = true;
dst->touchMs = now;
for (int i = 0; i < total; ++i)
dst->ids[i] = auraArray[i];
return;
}
// Stamp a guess for every spell ID present now but absent from the prior
// snapshot (a genuine appearance), then refresh the snapshot.
for (int slot = 0; slot < total; ++slot) {
const uint16_t id = auraArray[slot];
if (id == 0 || SnapshotHasId(*snap, id))
continue;
StampGroupGuess(guid, id, slot < buffCount ? KIND_HELPFUL : KIND_HARMFUL,
now);
}
for (int i = 0; i < total; ++i)
snap->ids[i] = auraArray[i];
snap->touchMs = now;
}
} // namespace Aura::Source
+43
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;
@@ -74,4 +102,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// Feeds the cache from an out-of-range group member's aura spell-ID array
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
// carries the spell IDs and the server sends it promptly when an aura is
// added/removed. So a spell ID that newly *appears* here did so ~one server
// tick + latency after the real application: we stamp a best-effort
// `now + base duration` expiration (base only — no remote caster mods, no
// caster recorded). Auras already present the first time we see a member have
// unknown age and are NOT stamped; only genuine absent->present transitions
// are. Idempotent within a frame; call it before enumerating the array so a
// just-appeared aura carries its guess on the same poll. The Store guard keeps
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
} // namespace Aura::Source
+15
View File
@@ -60,6 +60,8 @@
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Bag::UpdateDelayed {
namespace {
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
void OnWorldTick() {
if (!g_pending)
return;
// Bag changes aren't meaningful until the player is actually in the world.
// During any loading screen (initial login, zone/instance transition) the
// engine re-populates bag slots as objects stream in (setting g_pending)
// while still behind the screen; the per-frame world tick runs there too,
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
// mid-load for no real change. Hold the pending flag — do NOT clear it —
// until the engine's in-world flag is set; the first in-world frame then
// emits a single DELAYED covering the settled inventory (matching retail's
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
// so this re-suppresses on later zone changes too, not just initial login.
if (*reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
+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
+40 -5
View File
@@ -14,16 +14,21 @@
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
//
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
//
// This is a pure rename: Lua 5.0 ships the exact same C function under the
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register `math.fmod` as a direct alias of the engine's `math.mod` C
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
// (we don't touch it), so code written for either name works.
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
#include "Game.h"
#include "Offsets.h"
#include <cmath>
namespace BaseLib::MathLib {
namespace {
@@ -33,8 +38,38 @@ namespace {
const auto Script_math_fmod =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
// truncated toward zero and the fractional part carrying `x`'s sign
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
int __fastcall Script_math_modf(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: math.modf(x)");
return 0; // unreachable
}
double integral = 0.0;
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
Game::Lua::PushNumber(L, integral);
Game::Lua::PushNumber(L, fractional);
return 2;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
void *L = Game::Lua::State();
if (L != nullptr) {
Game::Lua::PushString(L, "math");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
Game::Lua::SetTop(L, -2); // pop [math]
} else {
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
}
}
}
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
+83
View File
@@ -0,0 +1,83 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
// exist whenever the DLL is injected, even if the addon is disabled. The
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
// Lua-side in the addon, mirroring retail's Mixin.lua.
//
// Behaviour matches the canonical Lua implementation exactly:
// Mixin(object, ...) -- shallow-copy every key/value from each table
// argument onto `object`; returns `object`.
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
// survive for the next Next() iteration.
Game::Lua::PushValue(L, -2); // key copy
Game::Lua::PushValue(L, -2); // value copy
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
}
}
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
return 0; // unreachable
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
int __fastcall Script_CreateFromMixins(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // new object at index top + 1
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
}
// Pure table helpers — register on both the in-game and glue states so they're
// always present (matching the "always available" intent).
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::Mixin
+189 -4
View File
@@ -11,16 +11,22 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction.
// - `string.gmatch(s, pattern)` — match iterator.
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
//
// Both are the same underlying pattern engine as functions 5.0 already ships:
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
// start/end indices — so we call the engine's `str_find` and transform.
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
@@ -33,6 +39,8 @@
#include "Offsets.h"
#include <cstddef>
#include <cstring>
#include <string>
namespace BaseLib::StringLib {
@@ -83,9 +91,186 @@ int __fastcall Script_string_match(void *L) {
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
for (const char *s = sep; *s != '\0'; ++s) {
if (*p == *s) {
isSep = true;
break;
}
}
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
// piece is treated as an empty string rather than erroring, so the delimiter
// placement stays predictable. `strjoin(",")` with no pieces returns "".
int __fastcall Script_strjoin(void *L) {
const char *delim = Game::Lua::ToString(L, 1);
if (delim == nullptr) {
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
return 0; // unreachable
}
const unsigned delimLen = Game::Lua::StrLen(L, 1);
const int top = Game::Lua::GetTop(L);
std::string out; // no Error() past this point — a longjmp would leak it
for (int i = 2; i <= top; ++i) {
if (i > 2)
out.append(delim, delimLen);
const char *piece = Game::Lua::ToString(L, i);
if (piece != nullptr)
out.append(piece, Game::Lua::StrLen(L, i));
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
return 1;
}
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
// both ends of `str` (default: whitespace). `chars` is a literal set of
// characters, not a pattern. Standard WoW global. Two-ended scan — no
// allocation, embedded NULs preserved (bounds come from the Lua length).
int __fastcall Script_strtrim(void *L) {
const char *str = Game::Lua::ToString(L, 1);
if (str == nullptr) {
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
: " \t\n\v\f\r";
bool trim[256] = {false};
for (const char *c = chars; *c != '\0'; ++c)
trim[static_cast<unsigned char>(*c)] = true;
unsigned start = 0, end = len;
while (start < end && trim[static_cast<unsigned char>(str[start])])
++start;
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
--end;
Game::Lua::PushLString(L, str + start, end - start);
return 1;
}
// `strreplace(str, find, replace)` — replace every occurrence of the literal
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
// needs no magic-character escaping. Empty `find` returns `str` unchanged
// (count 0) rather than looping forever.
int __fastcall Script_strreplace(void *L) {
const char *str = Game::Lua::ToString(L, 1);
const char *find = Game::Lua::ToString(L, 2);
const char *repl = Game::Lua::ToString(L, 3);
if (str == nullptr || find == nullptr || repl == nullptr) {
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
return 0; // unreachable
}
const unsigned strLen = Game::Lua::StrLen(L, 1);
const unsigned findLen = Game::Lua::StrLen(L, 2);
const unsigned replLen = Game::Lua::StrLen(L, 3);
if (findLen == 0) { // nothing to match — return input unchanged
Game::Lua::PushLString(L, str, strLen);
Game::Lua::PushNumber(L, 0.0);
return 2;
}
std::string out; // no Error() past this point
int count = 0;
unsigned i = 0;
while (i < strLen) {
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
out.append(repl, replLen);
i += findLen;
++count;
} else {
out.push_back(str[i]);
++i;
}
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 2;
}
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
// Lua length).
int __fastcall Script_string_reverse(void *L) {
const char *s = Game::Lua::ToString(L, 1);
if (s == nullptr) {
Game::Lua::Error(L, "Usage: string.reverse(s)");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
std::string out(len, '\0'); // no Error() past this point
for (unsigned i = 0; i < len; ++i)
out[i] = s[len - 1 - i];
Game::Lua::PushLString(L, out.data(), len);
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+1 -3
View File
@@ -13,8 +13,6 @@
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
C_Loot.LootAllCorpses()
</Binding>
</Bindings>
+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_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
// array of the currently-active chat-bubble Frame objects, matching
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
// reposition speech bubbles by reading the bubble's FontString region.
//
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
// they have no name and no Lua wrapper until something pushes them. This
// is exactly the default-nameplate case: we push each via the shared
// `UI::FrameObject::Push`, which delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
// wrapper. The returned frames respond to real frame methods — the
// FontString holding the spoken text (bubble + 0x31c, created with the
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
// "iterate GetRegions, find the FontString, read GetText()" idiom works
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
// care filter on `bubble:IsShown()` (a real method here) anyway.
//
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
}
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
&Script_GetAllChatBubbles);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace ChatBubble::Info
+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);
+170
View File
@@ -0,0 +1,170 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
// Windows clipboard and return the number of bytes copied. Vanilla has no
// clipboard access at all (Blizzard never exposed one), so this is a pure
// ClassicAPI addition backed by the Win32 clipboard API.
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
//
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include <string>
#include <windows.h>
namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
++i;
break; // the |t is dropped by the 't' case on the next iteration
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
return out;
}
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
// success. Does not free the allocation on success — ownership passes to the
// clipboard.
bool SetClipboardUtf8(const char *data, int byteLen) {
if (!OpenClipboard(nullptr))
return false;
bool ok = false;
if (EmptyClipboard()) {
// Empty string still writes a valid (empty) clipboard entry.
const int wlen =
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
if (h != nullptr) {
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
if (dst != nullptr) {
if (wlen > 0)
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
ok = true; // clipboard owns h now
}
if (!ok)
GlobalFree(h);
}
}
CloseClipboard();
return ok;
}
int __fastcall Script_CopyToClipboard(void *L) {
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
return 0; // unreachable
}
const char *text = Game::Lua::ToString(L, 1);
const unsigned len = Game::Lua::StrLen(L, 1);
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
int copiedLen;
if (removeMarkup) {
const std::string stripped = StripMarkup(text, len);
copiedLen = static_cast<int>(stripped.size());
SetClipboardUtf8(stripped.data(), copiedLen);
} else {
copiedLen = static_cast<int>(len);
SetClipboardUtf8(text, copiedLen);
}
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
}
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Clipboard::Copy
+292
View File
@@ -0,0 +1,292 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_ColorUtil` — the modern color-space + text-color-code helpers, backported
// to 1.12. Every one is pure math / string formatting (no engine state), so
// this is a plain reimplementation rather than a hook.
//
// Conventions verified in-game against a retail-family client that ships
// C_ColorUtil natively:
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
// no alpha channel).
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
//
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
// optional a field is honored (default 1.0) though retail's colorRGB type
// omits it — for the documented no-alpha case both give ff.
#include "Game.h"
#include <cmath>
#include <cstdio>
#include <string>
namespace Color::Util {
namespace {
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
int ToByte(double v) {
const double c = Clamp01(v);
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
}
double Max3(double a, double b, double c) {
double m = a > b ? a : b;
return m > c ? m : c;
}
double Min3(double a, double b, double c) {
double m = a < b ? a : b;
return m < c ? m : c;
}
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
// --- Pure conversions --------------------------------------------------
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
v = mx;
s = (mx <= 0.0) ? 0.0 : d / mx;
if (d <= 0.0) { // achromatic
h = -1.0;
return;
}
if (mx == r)
h = 60.0 * std::fmod((g - b) / d, 6.0);
else if (mx == g)
h = 60.0 * ((b - r) / d + 2.0);
else
h = 60.0 * ((r - g) / d + 4.0);
if (h < 0.0)
h += 360.0;
}
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
r = g = b = v;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double hh = h / 60.0;
const int i = static_cast<int>(std::floor(hh));
const double f = hh - i;
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * f);
const double t = v * (1.0 - s * (1.0 - f));
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
ho = h; // hue (incl. -1) passes through unchanged
l = v * (1.0 - sv / 2.0);
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
}
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
ho = h;
v = l + sl * MinLuma(l);
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
}
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic
r = g = b = l;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
const double hh = h / 60.0;
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
const double m = l - c / 2.0;
double r1, g1, b1;
switch (static_cast<int>(std::floor(hh)) % 6) {
case 0: r1 = c; g1 = x; b1 = 0.0; break;
case 1: r1 = x; g1 = c; b1 = 0.0; break;
case 2: r1 = 0.0; g1 = c; b1 = x; break;
case 3: r1 = 0.0; g1 = x; b1 = c; break;
case 4: r1 = x; g1 = 0.0; b1 = c; break;
default: r1 = c; g1 = 0.0; b1 = x; break;
}
r = r1 + m;
g = g1 + m;
b = b1 + m;
}
// --- Lua glue ----------------------------------------------------------
// Read arg `n` as a number, or raise the usage error.
double Arg(void *L, int n, const char *usage) {
if (!Game::Lua::IsNumber(L, n)) {
Game::Lua::Error(L, "%s", usage);
return 0.0; // unreachable
}
return Game::Lua::ToNumber(L, n);
}
// Read a numeric field from the color table at (absolute, positive) index
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
double ColorField(void *L, int idx, const char *key, double dflt) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, idx);
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
Game::Lua::SetTop(L, -2); // pop the value
return v;
}
// "AARRGGBB" from a color table at `idx`, alpha default ff.
std::string HexFromColor(void *L, int idx) {
const int a = ToByte(ColorField(L, idx, "a", 1.0));
const int r = ToByte(ColorField(L, idx, "r", 0.0));
const int g = ToByte(ColorField(L, idx, "g", 0.0));
const int b = ToByte(ColorField(L, idx, "b", 0.0));
char buf[9];
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
return std::string(buf, 8);
}
int __fastcall Script_ConvertRGBToHSV(void *L) {
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
double h, s, v;
RgbToHsv(r, g, b, h, s, v);
Game::Lua::PushNumber(L, h);
Game::Lua::PushNumber(L, s);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSVToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
double r, g, b;
HsvToRgb(h, s, v, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_ConvertHSVToHSL(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
double ho, sl, l;
HsvToHsl(h, s, v, ho, sl, l);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sl);
Game::Lua::PushNumber(L, l);
return 3;
}
int __fastcall Script_ConvertHSLToHSV(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
double ho, sv, v;
HslToHsv(h, s, l, ho, sv, v);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sv);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSLToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
double r, g, b;
HslToRgb(h, s, l, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_GenerateTextColorCode(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
return 0; // unreachable
}
const std::string hex = HexFromColor(L, 1);
Game::Lua::PushString(L, hex.c_str());
return 1;
}
int __fastcall Script_WrapTextInColor(void *L) {
const char *text = Game::Lua::ToString(L, 1);
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
return 0; // unreachable
}
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
int __fastcall Script_WrapTextInColorCode(void *L) {
const char *text = Game::Lua::ToString(L, 1);
const char *code = Game::Lua::ToString(L, 2);
if (text == nullptr || code == nullptr) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
return 0; // unreachable
}
std::string out = std::string("|c") + code + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
&Script_ConvertRGBToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
&Script_ConvertHSVToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
&Script_ConvertHSVToHSL);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
&Script_ConvertHSLToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
&Script_ConvertHSLToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
&Script_GenerateTextColorCode);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
&Script_WrapTextInColor);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
&Script_WrapTextInColorCode);
}
// Pure helpers — useful on both the in-game and glue states.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Color::Util
+23 -38
View File
@@ -13,8 +13,10 @@
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
@@ -22,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
@@ -67,21 +54,20 @@ 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};
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
// we inline the one-liner here to avoid a header dance for a
// single-instruction conversion.
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
@@ -106,14 +92,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
// Light Quiver actually carries a non-zero family for bags themselves
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
// vanilla-era server data doesn't populate the field. **Individual
// items** (arrows, bullets, shards, herbs) reliably return their
// family, so addons that auto-route loot work correctly via
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -139,12 +124,12 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
// counted here — the client just doesn't carry the family bit for them. The
// result stays consistent with what `GetContainerNumFreeSlots` reports for
// those same bags.
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
+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) {
+8
View File
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
: "cannot resume non-suspended coroutine");
return -1;
}
if (Game::Lua::CheckStack(co, nargs) == 0) {
Game::Lua::PushString(L, "too many arguments to resume coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres) == 0) {
Game::Lua::PushString(L, "too many results to resume coroutine");
return -1;
}
Game::Lua::XMove(co, L, nres);
return nres;
}
+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 *>(
+20 -5
View File
@@ -88,12 +88,27 @@ void Persist() {
} // namespace
void EnsureLoaded() {
if (g_loaded)
return;
g_path = Storage::ResolveFilePath();
if (g_path.empty())
return; // session globals not yet populated; retry on next call
// Re-resolve the per-character path on every call rather than latching
// once. The injected DLL persists across a logout→character-select→login
// (no process restart), so a permanent `g_loaded` latch would leave the
// previous character's file path and cached sets in place — the next
// character would then read, and save into, character A's data. Detecting
// a path change here makes the cache self-heal on any character/account
// switch. Every mutation Persist()s immediately, so dropping the old
// in-memory sets never loses unsaved work.
std::string path = Storage::ResolveFilePath();
if (path.empty())
return; // session globals not yet populated; keep current, retry later
if (g_loaded && path == g_path)
return; // already loaded for this character
g_path = std::move(path);
g_sets.clear();
Storage::Load(g_path, &g_sets);
// Per-session "ignore this slot on next save" state belongs to the
// character it was set on — clear it when switching characters.
for (int i = 0; i < SLOT_COUNT; ++i)
g_ignoredForSave[i] = false;
g_loaded = true;
}
+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 =
+5 -3
View File
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
// Resolves the per-character file path. Returns an empty string until
// the engine has populated all three session globals (account, realm,
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
// world the values are stable for the rest of the session, so callers
// can cache after the first non-empty return.
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
// current character is in world — they change on a logout→login to a
// different character (the DLL is NOT reloaded across that transition),
// so callers must re-resolve and compare rather than caching the first
// non-empty return permanently. See `Data::EnsureLoaded`.
std::string ResolveFilePath();
// Reads the file at `path` and overwrites `outSets` with the parsed
+52 -78
View File
@@ -40,17 +40,19 @@
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
// themselves.
//
// Tooltip filling is done by calling back through Lua (`self:AddLine`
// etc.) rather than hitting the engine's tooltip-line internals
// directly. The Lua dispatch goes through the engine's own frame-
// method registry, so any addon-installed hook on AddLine (DBM,
// pfUI's tooltip skin, etc.) still runs.
// Tooltip lines are built with the engine's own primitives — the
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
// anchor then calls SetEquipmentSet with no trailing :Show().
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Data.h"
#include "equipmentset/Locations.h"
#include "equipmentset/Set.h"
#include "item/Record.h"
#include <cstdint>
@@ -58,57 +60,41 @@ namespace EquipmentSet::Tooltip {
namespace {
// Calls `self:Method()` with no Lua args. Stack at entry must have
// the tooltip at index 1 (the standard `frame:method()` self slot).
// Leaves the stack unchanged.
void CallSelfNoArgs(void *L, const char *methodName) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, methodName);
Game::Lua::GetTable(L, -2); // method = self[methodName]
Game::Lua::Insert(L, -2); // [method, self]
Game::Lua::Call(L, 1, 0);
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
// line-color setter reads. Alpha is always opaque.
uint32_t Pack(double r, double g, double b) {
auto ch = [](double v) {
const int i = static_cast<int>(v * 255.0 + 0.5);
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
};
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
}
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
void AddLine(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "AddLine");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0);
// Left-aligned colored line via the engine's raw add-line. The packed
// color is a local so its address stays valid across the call.
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
const uint32_t color = Pack(r, g, b);
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
// the header line — `SetText` is the standard vanilla entry for
// "first line of a fresh tooltip" and handles internal redraw state
// `AddLine` skips when it's the first content after a clear.
void SetText(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "SetText");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
}
// Format-and-add one localized count line. Calls
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
double r, double g, double b) {
// Adds one localized "N unit" line: `string.format(_G[globalName] or
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
// with the C `fallback` for servers stripped of the standard strings),
// then the native colored add-line.
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
const char *fallback, int n, double r, double g, double b) {
const int savedTop = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
const char *text = Game::Lua::ToString(L, -1);
if (text != nullptr)
AddLine(L, text, r, g, b);
AddColoredLine(self, text, r, g, b);
Game::Lua::SetTop(L, savedTop);
}
@@ -123,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
@@ -185,39 +160,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (s == nullptr)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const Tally t = TallySet(*s);
const int totalSlots = t.equipped + t.inInventory + t.missing;
// Trim the Lua stack to just the tooltip self-arg so our internal
// method-call sequences don't accumulate the original args past
// their useful lifetime. `setName` was a `const char *` lifted out
// by ToString — Lua's strings are pinned by reference count, so
// dropping the string from the stack doesn't invalidate the
// pointer for the lifetime of this call.
Game::Lua::SetTop(L, 1);
// Header — set name, white. Doubles as the "ClearLines" boundary;
// SetText replaces line 1 and clears subsequent lines in vanilla.
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
// Header — set name, white. Clear first so it lands as line 0
// (the clear resets the line count the add-line body indexes from).
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
// Body lines — try Blizzard's localized FORMAT_* globals first.
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
// override by reassigning `_G[name]` at runtime. The fallbacks
// only fire on servers stripped of the standard GlobalStrings.
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
totalSlots, 1.0, 1.0, 1.0);
if (t.equipped > 0) {
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
t.equipped, 0.0, 1.0, 0.0); // green
}
if (t.inInventory > 0) {
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
t.inInventory, 1.0, 1.0, 1.0);
}
if (t.ignored > 0) {
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
t.ignored, 0.5, 0.5, 0.5); // gray
}
// Missing slots: list each by name using the itemID we stored at
@@ -233,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;
@@ -255,18 +227,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
Game::Lua::Call(L, 2, 1);
const char *line = Game::Lua::ToString(L, -1);
if (line != nullptr)
AddLine(L, line, 1.0, 0.0, 0.0); // red
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
Game::Lua::SetTop(L, savedTop);
}
if (unnamedMissing > 0) {
// Pre-itemID-format files or items the cache has no record of.
// No Blizzard format global maps cleanly here; addons can
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
}
CallSelfNoArgs(L, "Show");
// Show ourselves — self is still at Lua stack index 1 (Script_Show
// reads it there). Retail's SetEquipmentSet shows internally too.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
+194 -7
View File
@@ -46,6 +46,22 @@ struct ReservedName {
ReservedName g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
bool g_writesEnabled = false;
// One-shot latch so a genuinely full event table (no NULL slot left to
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
// One grow attempt per in-game table build. Reset in `PrepareForReload`
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
bool g_growLatched = false;
// Debug: force a grow on the next table build regardless of headroom, so
// the (otherwise dormant) grow path can actually be exercised in-game.
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
bool g_forceGrowNext = false;
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
// and is exactly what the engine itself does when filling event table
@@ -61,14 +77,38 @@ char *SStrDup(const char *s) {
return fn(s, __FILE__, __LINE__);
}
// Walk the engine's event table from the END looking for a NULL-name
// Storm allocator/free — the same pair the engine uses for the event-table
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
void *SMemAlloc(unsigned size) {
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
int line, int flags);
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
}
void SMemFree(void *ptr) {
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
int flags);
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
fn(ptr, __FILE__, __LINE__, 0);
}
// Walk the engine's event table from the LOW end looking for a NULL-name
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
// through the 548-slot input names array (it only writes ~350 of the
// slots explicitly; the rest stay zero-initialized from .data). UP
// and DOWN walks both find these gaps — we go DOWN for convention with
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
// shows our events grouped at the tail, visually distinct from engine
// events.
// slots explicitly; the rest stay zero-initialized from .data) — a big
// contiguous hole around 42..106 alone dwarfs our reservation count.
//
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
// range (fixed event IDs 549..655, and they destructively clamp the table
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
// from the tail — so the top is the contested end. Claiming from the low
// engine gaps keeps us entirely out of their way and independent of their
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
// reservations, so a free slot is essentially always available; the
// skip-occupied invariant keeps this safe against future engine additions
// regardless of direction.
int TryClaim(const char *eventName) {
if (!g_writesEnabled)
return -1;
@@ -76,7 +116,7 @@ int TryClaim(const char *eventName) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = count - 1; i >= 0; --i) {
for (int i = 0; i < count; ++i) {
auto **namePtr = reinterpret_cast<const char **>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
@@ -88,9 +128,119 @@ int TryClaim(const char *eventName) {
return i;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
if (!g_warnedTableFull) {
g_warnedTableFull = true;
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
}
return -1;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
// table (frames can only register once it exists) but BEFORE any frame has
// joined a chain (it's the first registration, and we run before the
// original registers it). So every chain is still an empty self-sentinel,
// and the buffer can be moved safely.
//
// (This is what the removed `EnsureCapacity` got wrong: it ran from
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
// previous/glue table and always aborted — it never actually grew. The
// realloc logic itself was fine; only the timing was broken.)
//
// Grows only when the engine's low NULL gaps are fewer than our
// reservations — which never happens in practice (~200 gaps vs ~46
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
// (debug) forces it so the path can be exercised. It grows by reallocating
// the entry buffer with the engine's own Storm allocator and swapping the
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
// our buffer and frees the (shared) name strings via its per-entry helper,
// exactly as its own.
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
return;
int nulls = 0;
for (int i = 0; i < count; ++i)
if (*reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
++nulls;
const bool force = g_forceGrowNext;
g_forceGrowNext = false;
if (nulls >= g_reservedCount && !force)
return; // plenty of room — the common (only) real case
// Growing moves the buffer, invalidating every entry's self-referential
// list anchor, so it's safe ONLY while all chains are empty. We run
// before the first registration, so that holds — but VERIFY it (a live
// chain means someone registered earlier via a path we didn't intercept;
// moving the buffer would corrupt their subscription). Abort if so and
// fall back to claim-NULL.
for (int i = 0; i < count; ++i) {
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
Debug::Log::Printf(
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
return;
}
}
constexpr int kMargin = 16;
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
const int newCount = count + deficit + kMargin;
auto *newBuf = static_cast<uint8_t *>(
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
if (newBuf == nullptr)
return; // alloc failed — keep the old table; claiming still works
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
// per-entry init: copy the name pointer (shared string, not duplicated);
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
// the anchor as an empty self-sentinel at the new location. Old entries
// past the copy and all new entries are already zero-filled + get fresh
// anchors. Safe because every chain is empty.
for (int i = 0; i < newCount; ++i) {
auto *e = reinterpret_cast<uint32_t *>(
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
if (i < count) // copy name; new slots stay null
e[0] = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE);
const uint32_t anchor = static_cast<uint32_t>(
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
e[2] = anchor; // self-referential list anchor
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
}
// Publish base BEFORE count so the globals never advertise more entries
// than the buffer holds. Then free the OLD buffer only — the name
// strings live on in newBuf and are freed by the reload teardown.
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
count, newCount, nulls, g_reservedCount,
force ? ", FORCED" : "");
}
} // namespace
AutoReserve::AutoReserve(const char *name) {
@@ -132,7 +282,28 @@ int LookupByName(const char *name) {
return -1;
}
bool HasListeners(int slot) {
if (slot < 0)
return false;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || slot >= count)
return false;
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
// value is the empty self-sentinel — same test the grow-safety scan
// uses. So this is true iff at least one frame registered for the event.
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
return head != 0 && (head & 1) == 0;
}
void RetryClaims() {
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
// time to move the buffer.
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
@@ -145,6 +316,8 @@ void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = -1;
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
// Diagnostic Lua functions — registered via the auto-register pattern
@@ -190,10 +363,24 @@ int __fastcall Script_DumpAllEvents(void *) {
return 0;
}
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
// exercised: call it, then `/reload`. The grow fires at the first
// `RegisterEvent` after the rebuild (chains still empty) and logs
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
int __fastcall Script_ForceEventGrow(void *) {
g_forceGrowNext = true;
Debug::Log::Printf(
"[event] force-grow armed — /reload to trigger it on the next build");
return 0;
}
void RegisterDiagnostics() {
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
&Script_DumpAllEvents);
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
&Script_ForceEventGrow);
}
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
+9
View File
@@ -64,6 +64,15 @@ int Lookup(const char *name);
// fire ourselves (e.g., polyfilling missing event dispatches).
int LookupByName(const char *name);
// True iff at least one frame is currently registered for the event in
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
// past the live table.
bool HasListeners(int slot);
// Dispatches a custom event via the engine's printf-style event
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
+109
View File
@@ -0,0 +1,109 @@
// 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/>.
// `PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
// backported. Vanilla fires PEW with no args; backported addons expect the
// two booleans to distinguish a fresh login from a `/reload` from an
// instance/zone transition.
//
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
// two-boolean payload. Both fire paths read the same frame chain off the
// event's table slot, so the frames registered for PEW receive the args
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
// `if isInitialLogin then …` reads naturally.
//
// Flags, from signals we already produce — no PEW-specific engine hook:
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
// boot (initial launch + each logout return), so a set latch means "the
// next enter-world is a fresh login". The first PEW consumes it.
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
// "script re-init happened and it wasn't the fresh login" == a reload.
// - Instance/zone transition rebuilds neither, so both latches are clear →
// both false, matching retail.
#include "Game.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
namespace Event::EnteringWorld {
namespace {
// Set on glue-screen boot; a pending latch means the next enter-world is a
// fresh character login (from the login/char-select screen).
bool g_atGlue = false;
// Set when the in-game Lua state is (re)built (login or /reload).
bool g_scriptReloaded = false;
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
// in-game init (the table is rebuilt, though engine slots are stable) and
// re-resolved lazily at fire time.
int g_pewId = -1;
int PewId() {
if (g_pewId < 0)
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
return g_pewId;
}
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
// suppress the engine's no-arg fire; everything else passes through.
bool Intercept(int eventID) {
const int pew = PewId();
if (pew < 0 || eventID != pew)
return false;
const bool isInitialLogin = g_atGlue;
g_atGlue = false;
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
g_scriptReloaded = false;
// Re-fire through the variadic dispatcher with the two booleans. true → 1
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
// tail-jumps to pushnil).
auto *const kNil = static_cast<const char *>(nullptr);
if (isInitialLogin && isReloadingUi)
Event::Custom::Fire(pew, "%d%d", 1, 1);
else if (isInitialLogin)
Event::Custom::Fire(pew, "%d%s", 1, kNil);
else if (isReloadingUi)
Event::Custom::Fire(pew, "%s%d", kNil, 1);
else
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
return true; // handled — suppress the original no-arg PEW fire
}
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
// zone transition). Also re-arm the PEW slot lookup — the event table was
// rebuilt.
void OnInGameInit() {
g_scriptReloaded = true;
g_pewId = -1;
}
// Glue-screen boot: we're at the login/char-select screen, so the next
// enter-world will be a fresh login.
void OnGlueInit() { g_atGlue = true; }
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
} // namespace
} // namespace Event::EnteringWorld
+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/>.
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
// subscribed to `event`, returned as multiple return values (matching
// modern WoW's vararg signature — not a table). Empty result set returns
// nothing.
//
// The engine already keeps exactly this list: every `frame:RegisterEvent`
// appends the frame to a subscriber chain hanging off the matching event
// table entry. We find the entry by name (same table `Event::Custom`
// walks), then walk the chain the same way `Frame::RegisterEvent`
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
// push each frame via the shared `UI::FrameObject::Push` so callers get
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
//
// Event names resolve through `Event::Custom::LookupByName`, so this also
// works for our `AutoReserve`-claimed custom events, not just engine ones.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace Event::Registered {
namespace {
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const char *event = Game::Lua::ToString(L, 1);
if (event == nullptr)
return 0;
const int slot = Event::Custom::LookupByName(event);
if (slot < 0)
return 0;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr)
return 0;
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
// First pass: count subscribers so we can reserve stack up front — an
// event with many registered frames would otherwise risk overflowing
// the default Lua stack slack as we push.
int count = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
++count;
}
if (count == 0)
return 0;
Game::Lua::CheckStack(L, count);
int pushed = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = *reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr) {
UI::FrameObject::Push(L, frame);
++pushed;
}
}
return pushed;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
&Script_GetFramesRegisteredForEvent);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Event::Registered
+49
View File
@@ -0,0 +1,49 @@
// 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 "SignalHook.h"
#include "Game.h"
#include "Offsets.h"
namespace Event::SignalHook {
namespace {
// Chained at static-init time by each `AutoSubscribe` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
// so the head is valid when subscribers link in.
AutoSubscribe *g_head = nullptr;
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
// long-standing shape with a dummy EDX so the trampoline call matches the
// register layout, and forward `0` for it (unused by the callee).
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
FireNoArgs_t g_orig = nullptr;
void __fastcall Hook(int eventID, int /*edx*/) {
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
if (s->fn(eventID))
return; // a subscriber handled it — suppress the original fire
g_orig(eventID, 0);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
reinterpret_cast<void **>(&g_orig)};
} // namespace
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
} // namespace Event::SignalHook
+47
View File
@@ -0,0 +1,47 @@
// 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
// Shared interceptor for the engine's no-arg event dispatcher
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
//
// MinHook allows only ONE hook per target address, and more than one feature
// needs to intercept this dispatcher (loot silent-scan suppression, the
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
// per-frame tick — this module owns the single hook and fans out to
// subscribers. Declare a file-scope `static const
// Event::SignalHook::AutoSubscribe` in your module.
//
// An interceptor returns `true` when it has fully handled `eventID` and the
// engine's original no-arg fire must be SUPPRESSED — either because it
// swallowed the event (loot) or re-fired it a different way (the PEW payload
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
// `false` lets the event fall through to the next subscriber and ultimately
// the original. The FIRST subscriber to return `true` wins and the original
// is skipped, so subscribers must claim disjoint event ids.
namespace Event::SignalHook {
using Interceptor = bool (*)(int eventID);
struct AutoSubscribe {
explicit AutoSubscribe(Interceptor fn);
Interceptor fn;
AutoSubscribe *next;
};
} // namespace Event::SignalHook
+996
View File
@@ -0,0 +1,996 @@
// 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/>.
// Frame attributes + unit-frame mouseover/click backport.
//
// Backports `frame:SetAttribute` / `frame:SetAttributeNoHandler` /
// `frame:ClearAttribute` / `frame:GetAttribute` to 1.12 as native methods on the base Frame registry
// (every frame gets them, like the modern clients). Vanilla has no attribute
// system at all — it arrived in 2.0 with secure frames. Attributes are just a
// per-frame, case-insensitive key→value store (verified from the 3.3.5
// implementation: a name→Lua-value map plus a getter with prefix/name/suffix
// wildcard precedence). We store the map in the Lua registry keyed by the frame
// (registry[frame]) — invisible to Lua script and tamper-proof, like retail's
// C-side attribute store (see PushSub).
//
// The headline use — SecureUnitButton behavior, minus the secure wrapper (1.12
// has no combat lockdown or taint, so protected actions just work):
//
// unitFrame:SetAttribute("unit", "party1") -- hover → mouseover = party1
// unitFrame:SetAttribute("type1", "target") -- left-click → target party1
//
// ── Mouseover (needs C — the engine's mouseover is a GUID slot) ──
//
// Installing OnEnter/OnLeave would be clobbered by an addon's own
// `SetScript("OnEnter", …)` (pfUI sets `unit` before its scripts), and there's
// nothing to read from Lua anyway — the mouseover unit is an engine global. So
// we mirror retail / SuperWoW's `SetMouseoverUnit`: watch the engine's
// mouse-focus frame (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
// CFrameScriptObject* `GetMouseFocus` returns) once per frame on the shared
// WorldTick, and when it's a `unit`-attributed frame call the engine's FULL
// mouseover setter (FUN_00492890) — 1:1 with hovering the unit's 3D model:
// model highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the GUID slot.
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
// overwrites the slot while the cursor is over UI.
//
// ── Clicks (the frame's own OnClick — scoped, native) ──
//
// Clicks are events, not state, so they belong on the frame's OnClick — but
// ONLY on frames that opt in with a `type` attribute. When a `type*` attribute
// is set we install a chained OnClick on that frame (protected, because OnClick
// is Button-only). It mirrors retail's SecureActionButton_OnClick: resolve ONE
// verb per click from the modifier/button-qualified `type` attribute, perform
// it, done. No global hook, no handler on frames that never asked for one.
//
// Resolution (retail-style): the attribute is `[prefix]type[suffix]`, where the
// prefix is the held modifiers ("alt-"/"ctrl-"/"shift-") and the suffix is the
// button number (1=Left, 2=Right, …). So `type1`, `shift-type1`, `type` all
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
// default-interaction precedence — pending spell / cursor item cast/drop on the
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
// ground-target spells land at the unit's feet), `item` (uses the `item`
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
// deprecated `bag`/`slot` attributes also work), `macro` (from the
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
// the standard unit dropdown at the cursor via the addon's
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
//
// One verb per click is the point: because we own the click when a verb
// resolves (and only then chain the previous handler), a configured `type1` no
// longer runs *alongside* the addon's own conditional OnClick — that double
// dispatch (our fixed "target" + pfUI's ClickAction) was the conflict. An addon
// expresses all its click behavior as attributes; unconfigured clicks fall
// through to its OnClick (e.g. a right-click menu).
//
// Ordering / clobbering: it chains the handler present when `type*` is set, so
// set `type*` AFTER the frame's own `OnClick`. Addons that re-`SetScript`
// `OnClick` later (pfUI does on every raid relayout) replace our closure — so
// re-set `type*` after re-SetScript to recover. Re-wiring is safe: WireOnClick
// re-wraps a clobbered (Lua) handler but skips its own C closure, so it never
// double-chains. The frame must be a Button registered for the relevant clicks
// (`RegisterForClicks`) — left is the Button default; right needs
// `RegisterForClicks("RightButtonUp")`, which real unit frames do.
//
// `OnAttributeChanged` is a real SetScript-able handler: SetAttribute (not
// SetAttributeNoHandler) fires it with `this` = frame, `arg1` = name, `arg2` =
// value. Implemented by co-hooking the base-frame script-name resolver
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
#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"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unordered_map>
namespace Frame::Attributes {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// ---- small string helpers --------------------------------------------------
void LowerCopy(char *dst, const char *src, size_t n) {
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(src[i])));
dst[i] = '\0';
}
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
void BoundedCopy(char *dst, const char *src, size_t n) {
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
// dst = tolower(a .. b .. c). Truncates at n-1.
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
size_t i = 0;
const char *parts[3] = {a, b, c};
for (const char *s : parts)
for (; s && *s && i + 1 < n; ++s)
dst[i++] = static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
dst[i] = '\0';
}
// 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
// (ButtonSuffix + ReadModAttr) reads as a verb selector: type, type1..type5,
// shift-type1, alt-ctrl-type2, … . This is the exact set WireOnClick must
// (re)install for; keying the wiring off it (rather than a fixed
// type/type1/type2 list) means a middle-click (type3) or modifier-only config
// still installs the chained OnClick.
bool IsTypeAttr(const char *lname) {
const char *p = lname;
for (bool advanced = true; advanced;) {
if (std::strncmp(p, "alt-", 4) == 0) p += 4;
else if (std::strncmp(p, "ctrl-", 5) == 0) p += 5;
else if (std::strncmp(p, "shift-", 6) == 0) p += 6;
else advanced = false;
}
if (std::strncmp(p, "type", 4) != 0)
return false;
p += 4;
if (p[0] == '\0')
return true; // "type"
return p[0] >= '1' && p[0] <= '5' && p[1] == '\0'; // "typeN"
}
// ---- attribute storage (in the Lua registry, keyed by the frame) -----------
// Pushes the frame's attribute subtable onto the stack. Stored as
// registry[frame] in the Lua registry (LUA_REGISTRYINDEX) rather than as a
// field on the frame's own table — the registry is C-side and has no _G path,
// so the store is invisible to `pairs(frame)` and can't be read or tampered
// with from Lua, matching how retail keeps attributes C-side. With create=true,
// lazily creates and stores it. Returns true iff a subtable is left on top;
// false (nothing left on the stack) when absent and !create. 1.12 frames are
// immortal, so the strong registry key never leaks.
bool PushSub(void *L, int frameIdx, bool create) {
Game::Lua::PushValue(L, frameIdx); // [.., frame] (key)
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX); // [.., sub|nil]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return true; // [.., sub]
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the nil -> [..]
if (!create)
return false;
Game::Lua::NewTable(L); // [.., sub]
Game::Lua::PushValue(L, frameIdx); // [.., sub, frame] (key)
Game::Lua::PushValue(L, -2); // [.., sub, frame, sub] (value)
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[frame] = sub -> [.., sub]
return true;
}
// Copies string attribute `lname` (already lowercase) of the frame at
// frameIdx into buf. Returns true iff a string value was found. Restores the
// stack.
bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
const int top = Game::Lua::GetTop(L);
bool ok = false;
if (PushSub(L, frameIdx, false)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, lname);
Game::Lua::RawGet(L, si); // [.., sub, val]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, -1);
if (s) {
BoundedCopy(buf, s, n);
ok = true;
}
}
}
Game::Lua::SetTop(L, top);
return ok;
}
// If the frame's attribute subtable has a non-nil value for keyLower, leave that
// value on the stack top and return true; otherwise restore the stack and
// return false.
bool TryPushValue(void *L, int frameIdx, const char *keyLower) {
const int top = Game::Lua::GetTop(L);
if (PushSub(L, frameIdx, false)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, keyLower);
Game::Lua::RawGet(L, si); // [.., sub, val]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, si); // [.., val]
return true;
}
}
Game::Lua::SetTop(L, top);
return false;
}
// Unit token → GUID goes through `Unit::Identity::GuidForToken` (engine
// resolver + `"player"` fast-path, handling our focus / nameplateN / raw-GUID
// extensions). For recognized tokens it never raises; unit attributes are
// tokens by contract.
// ---- Lua-global call helpers (used by the click dispatcher) -----------------
//
// The click dispatch mirrors retail's SecureActionButton_OnClick: it's C. It
// performs engine actions by calling ordinary Lua globals (TargetUnit,
// IsAltKeyDown, …) and our own actions by calling the C++ module directly
// (Spell::AtUnit, Unit::Focus). Both run under the engine's protected OnClick
// invocation, so a Lua error inside one is caught there (no crash).
// The plain global-call primitives (push-if-function, call with no/one arg)
// live in Game::Lua now (PushGlobalFunction / CallGlobal / CallGlobalString) —
// shared with the other modules that dispatch to FrameXML globals by name. The
// two shapes below are specific enough to stay here.
// _G[name]() -> boolean (false if the global isn't a function). Distinct from
// Game::Lua::CallGlobal in that it returns the call's boolean RESULT (used for
// the IsAltKeyDown/IsShiftKeyDown modifier probes).
bool CallBoolGlobal(void *L, const char *name) {
const int top = Game::Lua::GetTop(L);
bool r = false;
if (Game::Lua::PushGlobalFunction(L, name)) {
Game::Lua::Call(L, 0, 1);
r = Game::Lua::ToBoolean(L, -1) != 0;
}
Game::Lua::SetTop(L, top);
return r;
}
// _G[table][method](a, b) — two string args (null → nil, since PushString maps
// NULL to nil), no results. Returns true iff the method resolved to a function
// and got called. Used when the target is our own C_* function but the call must
// cross the Lua boundary — e.g. C_Item.UseItemByName, whose bag walk does
// SetTop(L, 0); going through lua_call gives it its own stack frame so that reset
// clears only its frame, not the dispatcher's (a direct C++ call would wipe our
// stack — the reason `spell`, which is stack-free, calls Spell::AtUnit directly
// while `item` routes through here).
bool CallTableFunc2Str(void *L, const char *table, const char *method,
const char *a, const char *b) {
const int top = Game::Lua::GetTop(L);
bool called = false;
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // _G[table]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
const int t = Game::Lua::GetTop(L);
Game::Lua::PushString(L, method);
Game::Lua::GetTable(L, t); // _G[table][method]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushString(L, a);
Game::Lua::PushString(L, b);
Game::Lua::Call(L, 2, 0);
called = true;
}
}
Game::Lua::SetTop(L, top);
return called;
}
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
//
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
// To run macro text without depending on an addon, we replicate pfUI's shim in
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
// every other method is a harmless no-op (via an `__index` metamethod), then
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
// path the real chat box uses to dispatch a slash command / send a line.
// GetText: returns upvalue(1), the captured line.
int __fastcall MacroGetText_c(void *L) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
return 1;
}
// A no-op standing in for any edit-box method the parser happens to call.
int __fastcall MacroNoop_c(void *) { return 0; }
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
int __fastcall MacroIndex_c(void *L) {
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
return 1;
}
// Runs a single macro line through the stock chat parser.
void RunMacroLineC(void *L, const char *line, size_t len) {
if (len == 0) return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // fake editBox
const int obj = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
Game::Lua::SetTable(L, obj);
Game::Lua::NewTable(L); // metatable { __index = noop }
const int mt = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "__index");
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
Game::Lua::SetTable(L, mt);
// No lua_setmetatable binding — use the Lua global.
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushValue(L, mt);
Game::Lua::Call(L, 2, 0);
}
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushNumber(L, 1); // send = 1
Game::Lua::Call(L, 2, 0);
}
Game::Lua::SetTop(L, top);
}
// Runs macro text one line at a time — a real macro is line-delimited, and a
// single ChatEdit_ParseText call only dispatches one command.
void RunMacroTextC(void *L, const char *text) {
if (!text) return;
for (const char *p = text; *p;) {
const char *nl = p;
while (*nl && *nl != '\n') ++nl;
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
p = (*nl == '\n') ? nl + 1 : nl;
}
}
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
// Keyed by the same pointer the engine stores at the mouse-focus slot. Single-
// threaded (Lua + WorldTick on the main thread), so no synchronization needed.
std::unordered_map<const void *, std::string> g_unitByFrame;
// The last GUID *we* wrote into the mouseover slot. We only touch the slot on a
// change of our own value, so when the cursor isn't over a unit frame (guid 0)
// we never clobber the engine's own 3D mouseover.
uint64_t g_lastSet = 0;
const void *CurrentMouseFocus() {
const void *ctx = *reinterpret_cast<const void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
if (ctx == nullptr)
return nullptr;
return *reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
}
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
// prevHi)`. Highlights the unit's model, builds the mouseover tooltip, fires
// UPDATE_MOUSEOVER_UNIT, and writes the GUID slot; pass 0,0 for the optional
// previous, and (0,0,0,0) to clear. Same address `Unit::Mouseover` co-hooks, so
// calling it here goes through that hook (loss events keep firing correctly).
void CallEngineMouseover(uint64_t guid) {
reinterpret_cast<void(__stdcall *)(uint32_t, uint32_t, uint32_t, uint32_t)>(
static_cast<uintptr_t>(Offsets::FUN_SET_MOUSEOVER_UNIT))(
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();
uint64_t guid = 0;
if (focus != nullptr) {
auto it = g_unitByFrame.find(focus);
if (it != g_unitByFrame.end())
guid = Unit::Identity::GuidForToken(
it->second.c_str()); // live — follows target changes
}
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;
}
}
// ---- click dispatch (the frame's own chained OnClick) ----------------------
// Runs the chained-onto previous handler (upvalue 1), vanilla-style with no
// args (it reads the `this`/`arg1` globals the engine set for this invocation).
void ChainOld(void *L) {
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(1)) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
Game::Lua::Call(L, 0, 0);
}
}
// Builds the modifier prefix ("alt-"/"ctrl-"/"shift-", in that order — retail's
// order) from the live key state, e.g. "alt-shift-". Empty when none held.
void BuildModifierPrefix(void *L, char *buf, size_t n) {
buf[0] = '\0';
size_t i = 0;
auto add = [&](const char *s) {
for (const char *p = s; *p && i + 1 < n; ++p)
buf[i++] = *p;
buf[i] = '\0';
};
if (CallBoolGlobal(L, "IsAltKeyDown")) add("alt-");
if (CallBoolGlobal(L, "IsControlKeyDown")) add("ctrl-");
if (CallBoolGlobal(L, "IsShiftKeyDown")) add("shift-");
}
// Button name -> attribute suffix (retail's convention: the button number).
const char *ButtonSuffix(const char *btn) {
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
}
// Resolves a modified attribute into `buf`. Precedence (practical subset of the
// GetAttribute wildcard rules): prefix..name..suffix (e.g. "shift-type1"),
// then name..suffix ("type1"), then name ("type"). So a plain `type1` still
// applies under any modifier unless a modifier-specific attribute overrides it.
bool ReadModAttr(void *L, int fi, const char *prefix, const char *name,
const char *suffix, char *buf, size_t n) {
char key[128];
Compose3Lower(key, sizeof key, prefix, name, suffix);
if (CopyAttr(L, fi, key, buf, n)) return true;
Compose3Lower(key, sizeof key, "", name, suffix);
if (CopyAttr(L, fi, key, buf, n)) return true;
Compose3Lower(key, sizeof key, "", name, "");
return CopyAttr(L, fi, key, buf, n);
}
// Reads attribute `keyLower` as an int (stored as a number, or a numeric
// string) into *out. Leaves the stack as it found it. Returns true on success.
bool ReadAttrInt(void *L, int fi, const char *keyLower, int *out) {
bool ok = false;
if (TryPushValue(L, fi, keyLower)) { // value left on top
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NUMBER) {
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
ok = true;
} else if (t == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, -1);
char *end = nullptr;
const long v = s ? std::strtol(s, &end, 10) : 0;
if (s && end != s) {
*out = static_cast<int>(v);
ok = true;
}
}
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // pop the peeked value
}
return ok;
}
// Numeric analog of ReadModAttr (prefix..name..suffix precedence).
bool ReadModAttrInt(void *L, int fi, const char *prefix, const char *name,
const char *suffix, int *out) {
char key[128];
Compose3Lower(key, sizeof key, prefix, name, suffix);
if (ReadAttrInt(L, fi, key, out)) return true;
Compose3Lower(key, sizeof key, "", name, suffix);
if (ReadAttrInt(L, fi, key, out)) return true;
Compose3Lower(key, sizeof key, "", name, "");
return ReadAttrInt(L, fi, key, out);
}
// Parses a "bag slot" string (two integers, e.g. "0 1") into bag/slot. Returns
// false unless the whole string is exactly two integers, so a normal item name
// or a bare itemID ("12345") falls through to the name path.
bool ParseBagSlot(const char *s, int *bag, int *slot) {
char *end = nullptr;
const long b = std::strtol(s, &end, 10);
if (end == s) return false; // not a leading number
const char *p = end;
const long sl = std::strtol(p, &end, 10);
if (end == p) return false; // only one number
while (*end == ' ' || *end == '\t') ++end;
if (*end != '\0') return false; // trailing junk
*bag = static_cast<int>(b);
*slot = static_cast<int>(sl);
return true;
}
// _G[name](a, b) — two number args, no results. Returns true iff called.
bool CallGlobalNum2(void *L, const char *name, double a, double b) {
const int top = Game::Lua::GetTop(L);
const bool called = Game::Lua::PushGlobalFunction(L, name);
if (called) {
Game::Lua::PushNumber(L, a);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 2, 0);
}
Game::Lua::SetTop(L, top);
return called;
}
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
// 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 (Ascii::EqualCI(verb, "target")) {
if (!unit) return false;
// `unit="none"` clears the target (retail's SecureActionButton behavior).
if (Ascii::EqualCI(unit, "none")) {
Game::Lua::CallGlobal(L, "ClearTarget");
return true;
}
// Cursor / pending-spell take precedence, matching the engine's default
// unit interaction — cast the pending spell / drop the item on the unit
// instead of switching target. The two predicates are ours (direct C++);
// the actions are engine-only, so they go through the Lua globals (which
// are the engine's own entries, with token→GUID resolution).
if (Spell::AtCursor::IsPlacementActive())
Game::Lua::CallGlobalString(L, "SpellTargetUnit", unit);
else if (Cursor::Info::HasItem())
Game::Lua::CallGlobalString(L, "DropItemOnUnit", unit);
else
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
return true;
}
if (Ascii::EqualCI(verb, "assist")) {
if (!unit) return false;
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
return true;
}
if (Ascii::EqualCI(verb, "focus")) {
if (!unit) return false;
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
return true;
}
if (Ascii::EqualCI(verb, "spell")) {
if (!unit) return false;
char spell[128];
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
return false;
Spell::AtUnit::CastByName(spell, unit);
return true;
}
if (Ascii::EqualCI(verb, "item")) {
char item[128];
int bag, slot;
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
if (ParseBagSlot(item, &bag, &slot))
CallGlobalNum2(L, "UseContainerItem", bag, slot);
else
CallTableFunc2Str(L, "C_Item", "UseItemByName", item, unit);
return true;
}
// Deprecated form: no `item`, take the `bag` + `slot` attributes.
if (ReadModAttrInt(L, fi, prefix, "bag", suffix, &bag) &&
ReadModAttrInt(L, fi, prefix, "slot", suffix, &slot)) {
CallGlobalNum2(L, "UseContainerItem", bag, slot);
return true;
}
return false;
}
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))
RunMacroTextC(L, macro);
return true;
}
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
Game::Lua::CallGlobal(L, "SpellStopCasting");
return true;
}
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.
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
return true;
}
// Unknown verb → not handled here; the chained handler runs.
return false;
}
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
// frame (captured at install time, so we don't depend on the `this` global).
// Resolves one verb per click from the (modifier/button) `type` attribute and
// performs it — retail's one-action-per-click model. Only chains the previous
// handler when we DIDN'T own the click, so a configured `type1` no longer runs
// alongside the addon's own OnClick (that double-dispatch was the conflict).
int __fastcall OnClick_c(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2)); // frame
const int fi = Game::Lua::GetTop(L);
bool handled = false;
if (Game::Lua::Type(L, fi) == Game::Lua::TYPE_TABLE) {
char btn[32] = {0};
Game::Lua::PushString(L, "arg1");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const char *b = Game::Lua::ToString(L, -1);
if (b)
BoundedCopy(btn, b, sizeof btn);
}
Game::Lua::SetTop(L, fi); // drop arg1, keep frame
const char *suffix = ButtonSuffix(btn);
char prefix[24];
BuildModifierPrefix(L, prefix, sizeof prefix);
char verb[32];
if (ReadModAttr(L, fi, prefix, "type", suffix, verb, sizeof verb)) {
char unit[128];
const bool haveUnit =
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
handled = DispatchVerb(L, fi, prefix, suffix, verb,
haveUnit ? unit : nullptr);
}
}
Game::Lua::SetTop(L, top);
if (!handled)
ChainOld(L); // let the frame's own OnClick handle unconfigured clicks
return 0;
}
// Installs OnClick as a closure over (previous handler, frame). Operates on
// self at stack index 1. Raises if `self` isn't a Button (OnClick is a Button
// script) — callers run it under PCall.
//
// Self-healing against clobbering: addons re-`SetScript("OnClick", …)` their
// own handler (pfUI does it on every raid relayout via UpdateScripts), which
// replaces our closure. So we re-wire whenever a `type*` attribute is (re)set,
// but only when the current OnClick is a *Lua* handler (a real clobber) or nil
// — if it's already a C function it's our own closure, and re-wrapping would
// double-chain. `IsCFunction` cleanly distinguishes the two here (addon click
// handlers are Lua; the only C-function OnClick we ever see is ours).
int __fastcall WireOnClick(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushString(L, "OnClick"); // (self, name)
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (self, name, old)
if (Game::Lua::GetTop(L) < 3) // defensive
Game::Lua::PushNil(L);
Game::Lua::SetTop(L, 3); // (self, name, old|nil)
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_FUNCTION &&
Game::Lua::IsCFunction(L, 3) != 0)
return 0; // already our closure
Game::Lua::PushValue(L, 1); // (self, name, old, self)
Game::Lua::PushCClosure(L, &OnClick_c, 2); // upvalues (old, self)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
return 0;
}
// ---- OnAttributeChanged script (settable via SetScript, like retail) --------
//
// Backport of the modern `OnAttributeChanged` widget script: SetAttribute (but
// NOT SetAttributeNoHandler) fires the frame's OnAttributeChanged handler. 1.12
// has no such script, so we make it SetScript/GetScript/HookScript-able the same
// way Tooltip::SetEvents adds OnTooltipSet* — co-hook the frame script-name
// resolver (FUN_FRAME_SCRIPT_RESOLVER, the base every frame type delegates to)
// and, for the one name the engine doesn't know, hand back an external 8-byte
// {handler, context} cell. Frames are immortal in 1.12 (no destroy), so a cell
// keyed by the frame's C object pointer never goes stale.
//
// 1.12 passes script args as globals, not params (see OnClick_c reading arg1),
// so we fire with `this` = frame (bound by the invoker), `arg1` = name and
// `arg2` = the new value: `function() local name, value = arg1, arg2 … end`.
struct AttrScriptSlot { uint32_t v[2]; }; // {handler, exec context}
std::unordered_map<const void *, AttrScriptSlot> g_attrHandlers;
uint32_t *AttrSlotFor(const void *frame, bool create) {
auto it = g_attrHandlers.find(frame);
if (it != g_attrHandlers.end())
return it->second.v;
if (!create)
return nullptr;
AttrScriptSlot &s = g_attrHandlers[frame]; // node-based → stable address
s.v[0] = 0;
s.v[1] = 0;
return s.v;
}
// Resolver co-hook. The engine's __thiscall(frame, name) is modelled as
// __fastcall with a dummy edx — `name` lands on the first stack slot either way
// (same trick as Tooltip::SetEvents). The original returns 0 for a name it
// doesn't know; we then claim "OnAttributeChanged".
using FrameResolver_t = int(__fastcall *)(void *frame, void *edx, const char *name);
FrameResolver_t g_frameResolverOriginal = nullptr;
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 (Ascii::EqualCI(name, "onattributechanged"))
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
return 0;
}
const Game::HookAutoRegister _attrResolverHook{
Offsets::FUN_FRAME_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&FrameResolver_h),
reinterpret_cast<void **>(&g_frameResolverOriginal)};
// Fire OnAttributeChanged on `frame`, with arg1 = name and arg2 = the new value
// (still at stack[3] in DoSet). Recursion-guarded so a handler that itself calls
// SetAttribute doesn't loop. The invoker (FUN_FRAME_INVOKE_SCRIPT) binds `this`
// and runs the handler under its own protected pcall; it doesn't restore the
// stack top, so snapshot/restore around it (as Tooltip::SetEvents does).
int g_firingAttr = 0;
using InvokeAttrScript_t = void(__fastcall *)(uint32_t handler, void *frame);
void FireAttributeChanged(void *L, void *frame, const char *name) {
if (frame == nullptr || g_firingAttr > 0)
return;
uint32_t *slot = AttrSlotFor(frame, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
const int savedTop = Game::Lua::GetTop(L);
// Snapshot the caller's arg1/arg2 first. SetAttribute can be called from
// inside a running script handler that still needs them, and the invoker
// below neither saves the globals nor restores the stack top — so we save
// (onto the stack, below anything the invoker pushes), overwrite, fire,
// then put them back. 1.12 passes handler args as globals, not params.
Game::Lua::PushString(L, "arg1");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1]
Game::Lua::PushString(L, "arg2");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1, oldArg2]
const int oldArg1 = savedTop + 1;
const int oldArg2 = savedTop + 2;
Game::Lua::PushString(L, "arg1");
Game::Lua::PushString(L, name);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg1 = name
Game::Lua::PushString(L, "arg2");
if (savedTop >= 3)
Game::Lua::PushValue(L, 3); // the new value
else
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg2 = value
++g_firingAttr;
reinterpret_cast<InvokeAttrScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], frame);
--g_firingAttr;
// Restore arg1/arg2 from the snapshot (the fired handler may also have
// written them; the caller's values win).
Game::Lua::PushString(L, "arg1");
Game::Lua::PushValue(L, oldArg1);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::PushString(L, "arg2");
Game::Lua::PushValue(L, oldArg2);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::SetTop(L, savedTop);
}
// ---- the methods -----------------------------------------------------------
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
// otherwise never registers as hovered). Operates on self at index 1.
void EnableFrameMouse(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushBoolean(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
}
int DoSet(void *L, bool fireHandler) { // (self, name, value)
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
return 0;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
if (PushSub(L, 1, true)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, lname); // [.., sub, key]
Game::Lua::PushValue(L, 3); // [.., sub, key, value] (nil if absent)
Game::Lua::RawSet(L, si); // sub[lname] = value
Game::Lua::SetTop(L, si - 1); // drop sub
}
const bool isString = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING;
// `unit` drives the mouseover binding: index it by the frame's C object
// (the key the mouse-focus poll uses) and enable its mouse so it can be
// hovered.
if (std::strcmp(lname, "unit") == 0) {
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj != nullptr) {
if (isString) {
const char *tok = Game::Lua::ToString(L, 3);
const bool isNew = g_unitByFrame.find(obj) == g_unitByFrame.end();
g_unitByFrame[obj] = tok ? tok : "";
if (isNew)
EnableFrameMouse(L);
} else {
g_unitByFrame.erase(obj);
}
}
}
// A `type*` attribute makes the frame a clickable unit button: (re)install
// the chained OnClick (PCall-guarded — OnClick is Button-only). WireOnClick
// is idempotent and self-healing, so calling it on every `type*` set both
// avoids double-chaining and reinstalls after an addon clobbered our
// OnClick — set `type*` again after re-SetScript to recover. The handler
// reads all the attributes fresh at click time. IsTypeAttr covers every
// form the resolver reads (type, typeN, modifier-prefixed), so a middle-
// click or modifier-only binding still wires.
else if (IsTypeAttr(lname) && isString) {
Game::Lua::PushCClosure(L, &WireOnClick, 0);
Game::Lua::PushValue(L, 1); // arg = self
Game::Lua::PCall(L, 1, 0, 0); // ignore errors (non-Button frame)
}
if (fireHandler)
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), lname);
return 0;
}
int __fastcall Script_SetAttribute(void *L) { return DoSet(L, /*fireHandler*/ true); }
int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHandler*/ false); }
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
// `cleared` is true iff the attribute was set (and is now gone). Routes through
// DoSet's nil-value path so it also drops a `unit` mouseover binding, but does
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
// ClearAttribute firing no handler, unlike SetAttribute).
int __fastcall Script_ClearAttribute(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
return 0;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
const bool existed = TryPushValue(L, 1, lname); // leaves the value on top if set
if (existed)
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the peeked value
Game::Lua::SetTop(L, 2); // (self, name)
Game::Lua::PushNil(L); // (self, name, nil) → value = nil clears the key
DoSet(L, /*fireHandler*/ false); // retail ClearAttribute fires no OnAttributeChanged
Game::Lua::PushBool(L, existed);
return 1;
}
int __fastcall Script_GetAttribute(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
return 0;
}
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
if (Game::Lua::GetTop(L) >= 4 && Game::Lua::IsString(L, 3)) {
const char *pfx = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
const char *nm = Game::Lua::ToString(L, 3);
const char *sfx = Game::Lua::IsString(L, 4) ? Game::Lua::ToString(L, 4) : "";
char key[256];
Compose3Lower(key, sizeof key, pfx, nm, sfx);
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "*", nm, sfx);
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, pfx, nm, "*");
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "*", nm, "*");
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "", nm, "");
if (TryPushValue(L, 1, key)) return 1;
Game::Lua::PushNil(L);
return 1;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
if (TryPushValue(L, 1, lname))
return 1;
Game::Lua::PushNil(L);
return 1;
}
// ---- registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetAttribute", &Script_SetAttribute},
{"SetAttributeNoHandler", &Script_SetAttributeNoHandler},
{"ClearAttribute", &Script_ClearAttribute},
{"GetAttribute", &Script_GetAttribute},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
} // namespace
} // namespace Frame::Attributes
+153
View File
@@ -26,6 +26,12 @@
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `region:IsMouseOver([top, bottom, left, right])` — cursor-in-rect
// test with optional per-edge offsets, also on the REGION registry.
// - `region:GetRect()` — left, bottom, width, height, composed from the
// engine's Region getters. Also on the REGION registry.
// - `region:IsDragging()` — true while the region is the frame currently
// being moved/sized. Reads the engine's global drag target.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
@@ -34,6 +40,9 @@
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
// - `frame:GetEffectiveAlpha()` — own alpha × the parent chain's, the
// product WoW composites at render time. Frame-scoped (matches
// GetAlpha); walks the parents via the engine's own getters.
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
@@ -83,6 +92,119 @@ int __fastcall Script_GetSize(void *L) {
return 2;
}
// ---- IsMouseOver (Region base — all region types) --------------------------
// `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])` —
// true when the cursor is within the region's rect, each edge optionally
// shifted by the matching offset (added to that edge, modern semantics:
// +top/-bottom/-left/+right enlarges). Vanilla addons have always done this
// in Lua as MouseIsOver(); we register it as the native Region method later
// clients expose.
//
// The math mirrors the engine exactly: GetLeft/Right/Top/Bottom (Region
// registry, all types) and GetCursorPosition both return values scaled by the
// same k = FUN_0041ad70()*DAT_007ffd68 factor, but the rect getters also
// divide by the region's effective scale — so dividing the cursor by that same
// effective scale (read from obj+0x7C, the field GetLeft/GetEffectiveScale
// themselves read) puts both in the region's logical space. A getter returns
// nil for an unpositioned region → we report false.
// Calls a Region getter that returns one number; false if it pushed nil/none.
bool CallRegionNumber(void *L, uintptr_t fn, double *out) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(fn, L); // (self, value | nil)
if (!Game::Lua::IsNumber(L, 2))
return false;
*out = Game::Lua::ToNumber(L, 2);
return true;
}
int __fastcall Script_IsMouseOver(void *L) {
// Offsets first — the stack gets reshaped by the delegated getters.
const double offTop = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0.0;
const double offBottom = Game::Lua::IsNumber(L, 3) ? Game::Lua::ToNumber(L, 3) : 0.0;
const double offLeft = Game::Lua::IsNumber(L, 4) ? Game::Lua::ToNumber(L, 4) : 0.0;
const double offRight = Game::Lua::IsNumber(L, 5) ? Game::Lua::ToNumber(L, 5) : 0.0;
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
double left, right, top, bottom;
if (effScale == 0.0f ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom)) {
Game::Lua::PushBool(L, false); // no resolved rect
return 1;
}
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
const double x = Game::Lua::ToNumber(L, 1) / effScale;
const double y = Game::Lua::ToNumber(L, 2) / effScale;
left += offLeft;
right += offRight;
top += offTop;
bottom += offBottom;
const bool inside = x >= left && x <= right && y >= bottom && y <= top;
Game::Lua::PushBool(L, inside);
return 1;
}
// ---- GetRect (Region base — all region types) ------------------------------
// `region:GetRect()` → left, bottom, width, height. Composed from the
// engine's own Region getters (so all UI-scale conversion is its code).
// Matches retail: returns nothing for a region with no resolved rect —
// gated on GetLeft, which pushes nil when the position isn't resolved (a
// size-only region with no anchor has width/height but no rect).
int __fastcall Script_GetRect(void *L) {
double left, bottom, width, height;
if (!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left))
return 0;
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETWIDTH, &width);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETHEIGHT, &height);
Game::Lua::SetTop(L, 1);
Game::Lua::PushNumber(L, left);
Game::Lua::PushNumber(L, bottom);
Game::Lua::PushNumber(L, width);
Game::Lua::PushNumber(L, height);
return 4;
}
// ---- IsDragging (Region base — all region types) ---------------------------
// `region:IsDragging()` — true while this region is the frame currently
// being moved/sized (StartMoving/StartSizing active, before
// StopMovingOrSizing). The engine tracks a single drag target in the global
// UI context at +0xCFC; StopMovingOrSizing itself gates on
// `context+0xCFC == self`, which is exactly this predicate. Reading the
// global context (rather than self+0xA0) keeps it correct for non-frame
// regions — a texture/fontstring self can never match the frame stored
// there, so it reports false, as it should.
int __fastcall Script_IsDragging(void *L) {
void *self = Game::Lua::ResolveObject(L, 1);
void *ctx = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *dragTarget =
ctx != nullptr
? *reinterpret_cast<void *const *>(
reinterpret_cast<const uint8_t *>(ctx) +
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
: nullptr;
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
return 1;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
@@ -95,6 +217,33 @@ int __fastcall Script_SetShown(void *L) {
return 0;
}
// ---- GetEffectiveAlpha (Frame) ---------------------------------------------
// `frame:GetEffectiveAlpha()` — the frame's own alpha times every
// ancestor's alpha, i.e. the product WoW composites at render time (fading
// UIParent fades all children). Vanilla exposes only GetAlpha (own alpha);
// we walk self → parent → … via the engine's own GetAlpha (Frame) and
// GetParent (Region), so no field offsets or scale math are re-derived. The
// iteration cap is a defensive guard against a pathological chain (WoW frame
// trees are shallow; the loop normally ends when GetParent returns nil at
// the top of the tree).
int __fastcall Script_GetEffectiveAlpha(void *L) {
Game::Lua::SetTop(L, 1); // (obj) = self
double eff = 1.0;
for (int guard = 0; guard < 128; ++guard) {
CallScript(Offsets::FUN_SCRIPT_FRAME_GETALPHA, L); // (obj, alpha)
eff *= Game::Lua::ToNumber(L, 2);
Game::Lua::SetTop(L, 1); // (obj)
CallScript(Offsets::FUN_SCRIPT_REGION_GETPARENT, L); // (obj, parent|nil)
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL)
break;
Game::Lua::Remove(L, 1); // drop obj → (parent) becomes index 1
}
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, eff);
return 1;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
@@ -274,6 +423,9 @@ const Game::HookAutoRegister _setPointHook{
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
{"IsMouseOver", &Script_IsMouseOver},
{"GetRect", &Script_GetRect},
{"IsDragging", &Script_IsDragging},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
@@ -282,6 +434,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
+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
+328
View File
@@ -0,0 +1,328 @@
// 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/>.
// Modern positional script arguments — make every frame-script handler
// receive `(self, arg1..argN)` the way later clients do, on top of the
// vanilla `this`/`arg1`/`event` globals.
//
// frame:SetScript("OnMouseWheel", function() print(this, arg1) end) -- vanilla
// frame:SetScript("OnMouseWheel", function(self, delta) print(self, delta) end) -- modern
//
// Vanilla ALWAYS invokes a script handler with ZERO Lua arguments: the two
// runners the engine funnels every dispatch through — FUN_FRAME_INVOKE_SCRIPT
// (no-arg scripts) and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) — set
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
// 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
// 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
// retail (and Frame::Modern's HookScript). An OnEvent dispatch is detected
// structurally at the runner: the handler ref being invoked equals the frame's
// OnEvent slot handler (frame+OFF_FRAME_ONEVENT_SLOT). This is exact — each
// SetScript creates a distinct ref, so a function bound to two scripts still
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
//
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
// straight passthrough to the original runner (zero reimplementation risk).
// Opt-in because this lands on the hottest Lua path in the engine
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Frame::ScriptArgs {
namespace {
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
bool g_enabled = false;
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
constexpr int kMaxArgs = 19;
// --- raw engine primitives not exposed via Game::Lua -----------------------
// lua_rawgeti(L, idx, n) — push table_at_idx[n]. Used with idx = REGISTRY to
// 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);
// 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);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
int EnsureLuaRef(void *frame) {
auto *f = reinterpret_cast<uint8_t *>(frame);
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
}
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
void ArgName(char *buf, int k) {
buf[0] = 'a';
buf[1] = 'r';
buf[2] = 'g';
if (k < 10) {
buf[3] = static_cast<char>('0' + k);
buf[4] = '\0';
} else {
buf[3] = static_cast<char>('0' + k / 10);
buf[4] = static_cast<char>('0' + k % 10);
buf[5] = '\0';
}
}
// 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;
}
// 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 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 false;
// Headroom for the saved-old block plus the transient set/call pushes.
if (CheckStack(L, 2 * kMaxArgs + 16) == 0)
return false;
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
// exactly. When so, `event` (a global the dispatcher has set) is prepended
// as the first positional to mirror retail's (self, event, arg1..argN).
const bool isOnEvent =
haveThis &&
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
handlerRef;
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
Arg args[kMaxArgs];
const int n = ParseArgs(fmt, vaPtr, args);
// --- 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 = callBase + 1; // absolute index of the errfunc
}
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
if (haveThis)
RawGetI(L, REGISTRY_INDEX, selfRef); // self
else
PushNil(L);
if (isOnEvent) { // event goes before the args, retail-style
PushString(L, "event");
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, callBase); // drop the message handler + any leftover error object
// --- 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) ----------------
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
const void *vaPtr);
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 &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
// the switch enabled those fire with (self) too, which is the modern shape.
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 &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
g_origInvoke(handlerRef, frame);
}
const Game::HookAutoRegister _runArgsHook{
Offsets::FUN_FRAME_RUN_SCRIPT_ARGS,
reinterpret_cast<void *>(&RunArgs_h),
reinterpret_cast<void **>(&g_origRunArgs)};
const Game::HookAutoRegister _invokeHook{
Offsets::FUN_FRAME_INVOKE_SCRIPT,
reinterpret_cast<void *>(&Invoke_h),
reinterpret_cast<void **>(&g_origInvoke)};
// --- Lua toggle -------------------------------------------------------------
int __fastcall Script_SetModernScriptArgs(void *L) {
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_enabled);
return 1;
}
int __fastcall Script_GetModernScriptArgs(void *L) {
Game::Lua::PushBool(L, g_enabled);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
&Script_SetModernScriptArgs);
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
&Script_GetModernScriptArgs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::ScriptArgs
+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
+19 -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);
@@ -282,7 +282,25 @@ void RegisterLuaFunctions() {
&Script_C_FriendList_IsWhoQueryPending);
}
// Clear transient /who state on every in-game Lua (re)build — login OR
// /reload. The DLL survives a logout→character-select→login, so a query
// whose SMSG_WHO response never crossed that boundary would otherwise leave
// g_inFlight stuck (IsWhoQueryPending() wrongly true for the next character)
// and our VAR_WHO_TO_UI_FLAG override forced on (routing the next
// character's unrelated /who to the WhoList). ResetIfStuck only heals this
// lazily on a *subsequent* send; a fresh-init reset makes every character
// start clean. RestoreFlagIfOwned puts the engine flag back to the value we
// snapshotted before overriding. ModuleAutoRegister fires per login and per
// /reload, before any addon runs, so this always precedes a new burst.
void ResetState() {
g_inFlight = 0;
RestoreFlagIfOwned();
g_haveSent = false;
g_lastSendTick = 0;
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ModuleAutoRegister _resetOnInit{&ResetState};
} // namespace
+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
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestGameObjectPosition(gameObjectID)` → `xPos, yPos, distance` — the
// world position of the nearest game object with the given
// game-object-template ID, and its distance from the player in yards.
// Returns nothing when none is found (the `MayReturnNothing` contract).
//
// The game-object analog of `ClosestUnitPosition`; same shared scan
// (Object::ClosestByEntry), just filtering on the GameObject GUID type
// (`0xF110`) and `TYPEMASK_GAMEOBJECT`. Same retail-vs-vanilla caveat:
// this returns the nearest CURRENTLY-VISIBLE object of that entry rather
// than reading retail's static starting-zone database (which vanilla
// doesn't have).
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace GameObject::ClosestPosition {
namespace {
int __fastcall Script_ClosestGameObjectPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestGameObjectPosition(gameObjectID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_GAMEOBJECT, Guid::Type::GameObject, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestGameObjectPosition",
&Script_ClosestGameObjectPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace GameObject::ClosestPosition
+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;
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Glue` session helpers.
//
// `C_Glue.IsFirstLoadThisSession()` — glue-only. Returns true only while the
// FIRST glue screen since the process launched is showing, and false on
// every return to glue after a world login (a genuine world→glue logout;
// login↔character-select stays within the first glue session). Retail uses
// it so the login UI runs one-time startup (intro cinematic, news) only on
// the initial launch, not on each relog.
//
// Backed by a process-static boot counter, no engine state needed: the glue
// registration callback fires exactly once per glue boot
// (FUN_LOAD_GLUE_SCRIPT_FUNCTIONS — initial launch + each world→glue return),
// so the counter equals "how many glue screens this session" and the first
// one is boot #1. The glue Lua state is recreated per boot, so C_Glue is
// re-registered each time; the counter is process-static and survives that.
//
// `C_Glue.IsOnGlueScreen()` — true when a GlueXML screen is showing (no
// character logged in), false in the world. Registered on BOTH Lua states,
// and the answer is a compile-time constant per state: the glue and in-game
// Lua states are mutually exclusive (glue exists only when no character is in
// the world), so the glue registration returns true and the in-game one
// returns false — no runtime flag to consult. Available in both states so
// shared code that runs in either environment can branch on it.
#include "Game.h"
namespace Glue::Session {
namespace {
// Glue-screen boots since process start. 1 during the very first glue screen.
int g_glueBootCount = 0;
int __fastcall Script_IsFirstLoadThisSession(void *L) {
Game::Lua::PushBool(L, g_glueBootCount <= 1);
return 1;
}
int __fastcall Script_IsOnGlueScreen_True(void *L) {
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsOnGlueScreen_False(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
void RegisterGlue() {
++g_glueBootCount; // one glue boot per callback invocation
Game::Lua::RegisterTableFunction("C_Glue", "IsFirstLoadThisSession",
&Script_IsFirstLoadThisSession);
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_True);
}
void RegisterInGame() {
// In-world: only IsOnGlueScreen makes sense (and it's always false).
// IsFirstLoadThisSession is a glue concept, so it's not registered here.
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_False);
}
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
} // namespace
} // namespace Glue::Session
+112
View File
@@ -0,0 +1,112 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "group/MemberStats.h"
#include "Offsets.h"
namespace Group::MemberStats {
namespace {
// Both roster lookups are `__fastcall(u64 *guid) -> block*` (NULL on miss).
using Lookup_t = const uint8_t *(__fastcall *)(uint64_t *guid);
uint16_t U16(const uint8_t *p, int off) {
return *reinterpret_cast<const uint16_t *>(p + off);
}
} // namespace
Stats Lookup(uint64_t guid) {
Stats s;
if (guid == 0)
return s;
// Party stats block — the full record.
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
s.valid = true;
s.full = true;
s.powerType = e[Offsets::OFF_GROUP_MEMBER_STATS_POWER_TYPE];
s.health = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_POWER);
s.maxPower = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_POWER);
s.level = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_LEVEL);
s.areaId = U16(e, Offsets::OFF_GROUP_MEMBER_AREA_ID);
s.online = (e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
// The party stats block carries no dead / subgroup / rank fields; those
// are raid-only (dead comes from UnitIsDead on the live unit otherwise).
return s;
}
// Raid slot — same struct GetRaidRosterInfo reads.
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
s.valid = true;
s.full = false;
s.powerType = e[Offsets::OFF_RAID_SLOT_POWER_TYPE];
s.health = U16(e, Offsets::OFF_RAID_SLOT_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_RAID_SLOT_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_RAID_SLOT_POWER);
s.maxPower = U16(e, Offsets::OFF_RAID_SLOT_MAX_POWER);
s.level = U16(e, Offsets::OFF_RAID_SLOT_LEVEL);
s.areaId = U16(e, Offsets::OFF_RAID_SLOT_AREA_ID);
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_FLAGS);
s.online = (flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0;
s.dead = (flags & Offsets::RAID_SLOT_FLAG_DEAD) != 0;
s.subgroup = static_cast<int>(*reinterpret_cast<const uint32_t *>(
e + Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
s.rank = static_cast<int>(
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_RANK));
return s;
}
return s;
}
const uint16_t *AuraArray(uint64_t guid) {
if (guid == 0)
return nullptr;
// Party stats block first — auras at +0x1a, but only for an online member
// (the engine's Script_UnitBuff bails on the party path when the online bit
// is clear).
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
if ((e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) == 0)
return nullptr;
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
}
// Raid slot — auras at +0x64, no online gate (mirrors the engine's raid
// branch, which reads the array unconditionally).
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_RAID_SLOT_AURAS);
}
return nullptr;
}
} // namespace Group::MemberStats
+67
View File
@@ -0,0 +1,67 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Group::MemberStats {
// Cached stats for a party/raid member, read from the group roster
// (fed by SMSG_PARTY_MEMBER_STATS). This is the ONLY source for a member with
// no live CGUnit — a groupmate on a different map or otherwise out of range —
// and is exactly what the engine's UnitHealth / UnitMana fall back to. Any
// module that reads unit fields off the descriptor should consult this when
// the descriptor is unavailable so it doesn't report 0 for out-of-range
// groupmates.
//
// `full` distinguishes the two backing structures: the party stats block and
// the raid slot. Both carry health / power / level / area / online; `dead`,
// `subgroup`, and `rank` come only from the raid slot (0 / false on the party
// path). Power values are RAW (divide by the power divisor for display);
// health is the roster u16, matching what UnitHealth returns out of range.
struct Stats {
bool valid = false;
bool full = false; // true = party stats block; false = raid slot
int powerType = 0;
uint32_t health = 0;
uint32_t maxHealth = 0;
uint32_t power = 0; // raw (pre-divisor)
uint32_t maxPower = 0; // raw (pre-divisor)
int level = 0;
int areaId = 0; // AreaTable.dbc ID
bool online = false;
bool dead = false; // raid slot only
int subgroup = 0; // raid slot only, 1-based (0 = party path)
int rank = 0; // raid slot only
};
// Look up a member by GUID. Tries the party stats block, then the raid slot,
// mirroring the engine's UnitHealth/UnitMana out-of-range fallback. Returns an
// invalid `Stats` (`valid == false`) when the GUID isn't a rostered member.
// Pure — no Lua, no throw; safe from any context.
Stats Lookup(uint64_t guid);
// Pointer to an out-of-range groupmate's 48-slot aura spell-ID array (buffs
// 0..31, debuffs 32..47), or nullptr if `guid` isn't a rostered member — or is
// an *offline* party member (the engine surfaces no auras for those). Mirrors
// the engine's own out-of-range fallback in Script_UnitBuff/Script_UnitDebuff
// exactly: the party stats block at +OFF_GROUP_MEMBER_STATS_AURAS gated on the
// online flag, else the raid slot at +OFF_RAID_SLOT_AURAS. Spell IDs only —
// SMSG_PARTY_MEMBER_STATS carries no caster/duration/stacks, and truncates IDs
// to u16 (custom IDs > 65535 are wrong; same as the built-in UnitBuff). The
// pointer is into live engine memory — read it immediately, don't cache it.
// Pure — no Lua, no throw; safe from any context.
const uint16_t *AuraArray(uint64_t guid);
} // namespace Group::MemberStats
+13 -2
View File
@@ -21,7 +21,7 @@
namespace Item::Arg {
Resolved Resolve(void *L, int idx) {
Resolved out{0, nullptr};
Resolved out{0, 0, nullptr};
if (Game::Lua::IsNumber(L, idx)) {
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
return out;
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
return out;
}
if (const char *m = std::strstr(s, "item:")) {
out.itemID = std::atoi(m + 5);
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
out.suffix = std::atoi(p + 1);
break;
}
}
return out;
}
// Plain string — try numeric, otherwise expose as a name for
+1
View File
@@ -22,6 +22,7 @@ namespace Item::Arg {
// can ignore `name` and treat `itemID == 0` as "no usable input".
struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
};
+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;

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