Compare commits

...
54 Commits
Author SHA1 Message Date
Brues b8d55e8dea Back UnitCastingInfo / UnitChannelInfo for remote units
Vanilla stores no cast record for other units, so UnitCastingInfo(other)
returned nil and remote channels had no timing. Co-hook the SMSG_SPELL_START
handler (0x006E7640, gated on opcode 0x131) -- the only packet carrying
another unit's cast with a server-authoritative cast time -- and cache it
per caster GUID (cast vs. channel split by AttributesEx channel bits).

- UnitCastingInfo(unit): full timing for any unit within its cast window;
  enemy/target/focus/nameplate cast bars now work, not just the player.
- UnitChannelInfo(unit): remote channels get real start/end times when the
  channel's begin was observed, validated against the live +0x228 field so a
  stale entry never sticks; falls back to the old no-times path otherwise.
- Player path unchanged (self-tracked); the cache skips caster==PlayerGuid.

Factor the CDataStore packet reader out of Aura::Source into a shared
Net::PacketReader header (vtable-correct) used by both packet hooks.

Best-effort: only casts begun while the unit was in range are seen, and an
interrupted remote cast lingers until its computed end (1.12 sends no
per-unit interrupt packet to hook).
2026-06-25 00:45:38 -05:00
Brues 5550cb02b7 Fill AuraData.expirationTime for proc / triggered auras
SpellGo only sees directly-cast auras, so proc-applied debuffs (Shadow
Weaving etc.) had no expiration. Co-hook two more engine functions that
fire for every aura application:

- CGUnit_C::OnAuraAdded (0x006123F0) - any aura newly occupying a slot,
  procs included; stamps now + base duration (no caster).
- CGUnit_C::OnAuraStacksChanged (0x00612450) - re-stamps on a stack-count
  change so a climbing stacking debuff (Shadow Weaving 1->5) refreshes.

Both pass fromCast=false to Store, which skips entries SpellGo already owns,
so directly-cast auras keep their talented timing + caster regardless of
hook order. Consolidate the object-GUID read into Unit::Identity::
GuidForObject so the lookup and the new hooks key the cache identically.

Known limitation (documented): a refresh at MAX stacks (5->5) changes no
client-visible aura field, so the server sends no UpdateField and no
callback fires - there's no packet to hook. The countdown runs out and
expirationTime falls back to 0 (unknown) while the aura still shows.
2026-06-24 17:36:56 -05:00
Brues 492c70d156 Use applied (caster-modified) duration for AuraData.duration
duration came from a base Spell.dbc/SpellDuration.dbc lookup while
expirationTime came from the Aura::Source cache computed with the caster's
duration modifiers. For a talented DoT (e.g. 2/2 Improved Shadow Word: Pain,
18s -> 24s) the two disagreed, so remaining (expirationTime - GetTime())
exceeded duration -- impossible for a single aura.

Cache the applied durationMs alongside expirationMs in Aura::Source and have
Push prefer it for the duration field, keeping the two consistent
(remaining <= duration). Falls back to the base DBC value on a cache miss.
2026-06-24 16:57:34 -05:00
Brues c68ec17e0c Honor the PLAYER aura filter in C_UnitAuras
With the Aura::Source caster cache in place, the PLAYER filter token is now
derivable: restrict results to auras whose cached caster GUID matches the
local player (Unit::Identity::PlayerGuid). Wired into every getter that
takes a filter — GetAuraDataByIndex, GetUnitAuraBySpellID,
GetAuraDataBySpellName, and GetUnitAuras — via a playerOnly flag threaded
into the slot finders and the new Data::IsPlayerCast predicate.

Auras whose cast we didn't observe (predate login / no SMSG_SPELL_GO) count
as not-player-cast and are excluded, matching the best-effort caster model.

Also fix GetUnitAuras range selection so a bare "PLAYER" (no HELPFUL/HARMFUL)
returns both ranges restricted to player casts, instead of defaulting to
helpful-only.

RAID / CANCELABLE / nameplate filter tokens remain accepted-but-no-op.
2026-06-24 15:21:33 -05:00
Brues b17aeda203 Add aura caster + non-player expiration to C_UnitAuras
Populate AuraData sourceUnit/sourceGUID and non-player expirationTime by
co-hooking SMSG_SPELL_GO (FUN_SPELL_GO) and caching (targetGuid, spellId)
-> caster + expiration. The vanilla unit aura array stores only spell IDs;
the SpellGo packet is the only place the client sees an aura's caster and a
server-authoritative duration. We parse the engine packet ourselves, so
this is self-sufficient (no nampower dependency) -- when nampower is loaded
we simply co-hook the same site.

sourceGUID is a ClassicAPI extension (raw "0x..." string), set whenever a
caster is known including when sourceUnit is nil; stable across the session,
unlike the volatile nameplate token, and usable as a unit token under
SuperWoW.

Also consolidate the local-player-GUID read into Unit::Identity::PlayerGuid()
(was duplicated inline in four files) and expose Unit::Identity GuidForToken
/ TokenFromGUID for reuse.
2026-06-24 15:10:53 -05:00
Brues 23d2fe4bbb Update README.md 2026-06-24 12:14:56 -05:00
Brues f6a8b850e6 Point install instructions at the latest release
Lead with downloading the prebuilt ClassicAPI.dll from
/releases/latest (build-from-source is the alternative), and note the
bundled !!!ClassicAPI addon ships inside the DLL so there's no separate
addon install.
2026-06-24 12:10:02 -05:00
Brues aac41faab9 Add C_GameObjectInfo (GetGameObjectInfoByID + RequestLoadGameObjectByID)
GameObject analog of C_CreatureInfo, reading the gameobject cache
(gameobjectcache.wdb, SMSG_GAMEOBJECT_QUERY_RESPONSE):
- GetGameObjectInfoByID -> { gameObjectID, name, type, displayID } | nil
  (synchronous peek)
- RequestLoadGameObjectByID -> bool, fires GAMEOBJECT_DATA_LOAD_RESULT
  when an uncached GO arrives.

The GO cache is a sibling class of the creature cache: same hash-map
shape but a different vtable and a larger entry (loaded flag at
entry+0x98 vs +0x50), so it has its own _GetRecord (FUN_00558560) and
response parser (FUN_005588E0). Generalized Cache::QueryLoad to take a
per-cache _GetRecord via Register and to hook BOTH parsers (creature +
gameobject), dispatching completion by cache instance. Renamed the
creature offsets FUN_CACHE_* -> FUN_CREATURE_* now that "generic" was a
misnomer.

GO field offsets: name@+0x08 verified (FUN_0062dc10 reads [block+0x08]),
type@+0x00 / displayID@+0x04 from the wire/WDB order. Verified in-game:
Mesa Elevator 47297 type 11 displayID 360, Windrunner 176250 type 15
displayID 7087, and RequestLoadGameObjectByID populated an uncached GO
("Lighting Impact Large", type 5). Creature path re-verified after the
refactor (GetCreatureInfoByID + the load event still work).
2026-06-23 19:43:35 -05:00
Brues f119f448ed Add C_CreatureInfo.RequestLoadCreatureByID + shared Cache::QueryLoad
RequestLoadCreatureByID asynchronously fetches an uncached creature:
issues SMSG_CREATURE_QUERY and fires CREATURE_DATA_LOAD_RESULT(id,
success) when the response lands (or on timeout); fires success
synchronously on a cache hit. Mirrors C_Item.RequestLoadItemData.

New Cache::QueryLoad dispatcher generalizes Item::Data's
pending/timeout/event machinery so multiple query caches share one
response-handler hook. MinHook allows only one hook per target, and
the creature/gameobject (and other non-item/quest) caches all route
their SMSG_*_QUERY_RESPONSE through the generic parser FUN_00556E20 —
so we hook it once and dispatch by cache instance to the right pending
set + completion event. Item/quest use their own parsers, so no
collision. The gameobject cache will register on this same dispatcher.

Verified in-game: RequestLoadCreatureByID(10184) on an uncached Onyxia
returned true, fired CREATURE_DATA_LOAD_RESULT, and populated the cache
(type 2 Dragonkin, rank 3 world boss, displayID 8570).
2026-06-23 19:28:16 -05:00
Brues 44943bfab1 Move C_CreatureInfo.GetCreatureID docs into the Creature section
GetCreatureID was catalogued under NameCache; with the new Creature
section it belongs there next to GetCreatureInfoByID. Moved the README
row entry and the API.md TOC + section body, fixed the now-stale
"above" cross-ref to C_PlayerInfo.GUIDIs*, and linked the two
C_CreatureInfo calls (GetCreatureInfoByID(GetCreatureID(guid))).
2026-06-23 19:14:50 -05:00
Brues 73d2c2f320 Add C_CreatureInfo.GetCreatureInfoByID(creatureID)
Reads the client-side creature cache (creaturecache.wdb, fed by
SMSG_CREATURE_QUERY_RESPONSE) for an NPC by ID and returns
{ creatureID, name, subName, type, family, rank, displayID } — or nil
if not cached. A synchronous peek (no network query): answers for any
cached creatureID, unlike UnitCreatureType/UnitClassification which
need a live unit token.

Cache machinery is the same generic query-cache class as Item/Quest:
instance 0x00C0E354, generic _GetRecord FUN_00556AA0 (callback=0 peeks).
Field offsets verified against the binary — rank read by the engine's
classification helper FUN_00605620 as [block+0x20], anchoring the
wire/WDB field order (Name[4], SubName, typeFlags, type, family, rank,
…) — and cross-checked in-game against real creaturecache.wdb rows
(Misthoof Stag 61332 type 1; Nordrassil Nymph 61582 type 7 rank 1;
Greathorn Hunter 61337 family 26). All match.

RequestLoadCreatureByID (async fetch for uncached creatures) + the
gameobject cache follow.
2026-06-23 19:11:30 -05:00
Brues 762968d24d Add ExportDBCFiles console command
Companion to ExportInterfaceFiles: dumps every .dbc table the client
loads to DBFilesClient\ next to the exe. Reuses the same MPQ
enumeration + file-read pipeline (factored into a shared WriteFiles),
and unions two sources for a complete set:

  1. MPQ (listfile) under DBFilesClient\ — catches archive files with
     no loader (e.g. wowerror_strings.dbc).
  2. A .text scan for the DBC path-getter pattern
     (mov eax, &"DBFilesClient\X.dbc"; ret) — the authoritative list of
     what the build loads. The master DBC init (FUN_0053f8b0) is
     straight-line with no iterable name table, so the per-DBC getters
     are the registry; scanning for them is the technique that built
     docs/DBCs.md. Bounded reads, can't fault.

Listfile alone yielded 137; the union yields 155 (+18 cosmetic
anim/sound/lookup DBCs the listfile omits, 0 lost). Results deduped
case-insensitively. Verified in-game: old vs new extract diff is
exactly the 18 expected additions.
2026-06-23 16:35:45 -05:00
Brues d2672d818d Move cast/channel info under C_Spell to free the global names
UnitCastingInfo / CastingInfo / UnitChannelInfo / ChannelInfo were
registered as globals (mirroring modern WoW), but that clobbers addons
that ship their own vanilla cast-tracking via the
`_G.UnitCastingInfo or <fallback>` idiom — notably ShaguTweaks'
libcast.lua, which scrapes the combat log for remote/enemy casts the
1.12 engine never exposes. With our global present those addons adopt
our player-only version and drop their (superior, for enemies)
fallback, killing enemy cast bars. We can't match the combat-log
scraper in the DLL (the engine carries no remote regular-cast data),
so cede the global names and register under C_Spell instead.

Tradeoff: addons calling the global UnitCastingInfo/UnitChannelInfo
must use the C_Spell.-prefixed names now; enemy-cast consumers got nil
from the global anyway.
2026-06-23 12:08:10 -05:00
Brues 17f44f93f8 Add C_Item.GetEnchantInfo(enchantID)
Resolves an item-enchantment ID (the enchantID from
C_Item.GetWeaponEnchantInfo, and item permanent-enchant IDs) into its
SpellItemEnchantment.dbc record:

  { enchantID, name, effects = {{type,amount,arg},...}, spellID }

- name: localized display name ("Crusader", "Brilliant Mana Oil").
- effects: each active slot's ITEM_ENCHANTMENT_TYPE / amount / arg.
- spellID: convenience for spell-type enchants (1/3/7) — the proc/
  equip/use spellID, e.g. Crusader -> 20007 "Holy Strength", to chain
  into C_Spell.GetSpellDescription.

Record layout verified by parsing the on-disk DBC against known
records (Crusader 1900: type 1, arg 20007; Sharpened+3 id 13: type 2,
amount 3; Reinforced Armor+8 id 15: type 4, amount 8): Type[3]@+0x04,
Amount[3]@+0x10, EffectArg[3]@+0x28, Name[8]@+0x34. The engine's
by-ID readers only touch name/flags/condition, so the effect columns
couldn't be pinned from disassembly — the extracted dbc/ dump was the
oracle. Verified in-game across all three example enchants.

Placed in C_Item (not C_Spell): the id originates from
C_Item.GetWeaponEnchantInfo and the concept is an item enchantment.
Source-item is not derivable (no back-reference, no client reverse
index).
2026-06-23 02:00:52 -05:00
Brues 73d90107b0 Drop the redundant registration latch
The console registrar (FUN_0063F9E0) dedups by name, and we pass the
same string literal so its pointer-equality fast path makes a repeat
call a hard no-op. Verified in-game: after a logout→login cycle
(which re-fires the glue hook), ExportInterfaceFiles stays listed once
in `help debug` and still runs. The static latch was unnecessary.
2026-06-22 22:52:16 -05:00
Brues ed389591ac Register ExportInterfaceFiles once, at glue boot
Console commands are process-global and persist for the session, so a
single registration at glue boot (the earliest hook, fires at launch)
covers both the login console and in-world — confirmed in-game. Drop
the redundant in-game ModuleAutoRegister; it only ever hit the latch.
2026-06-22 22:45:17 -05:00
Brues 1867262cd9 Export Blizzard addons under Interface\AddOns
The AddOns\ skip dropped Blizzard's own MPQ-baked UI addons
(Blizzard_AuctionUI, Blizzard_TalentUI, …) — part of the stock UI
source. Loose on-disk addons are never in an archive listfile, so
nothing surfaces them (verified: both branches of FUN_00401470 walk
MPQ listfiles / an in-memory registered-file list, not the loose disk
dir); the skip was both wrong and unnecessary. Matches 4.3.4
(extension filter only, no path exclusion).
2026-06-22 22:38:05 -05:00
Brues 9b21f983b3 Add ExportInterfaceFiles console command
Backport of the 4.3.4 developer-console command: dumps Blizzard's UI
files out of the mounted MPQs to disk (`ExportInterfaceFiles code`
writes .lua/.xml/.toc/.xsd to BlizzardInterfaceCode\; `art` writes
.blp/.tga to BlizzardInterfaceArt\), preserving the Interface\ subtree
and skipping the user's own AddOns\.

- New src/interface/Export.cpp: enumerates each archive's (listfile)
  via the MPQ walker FUN_00401470, reads each entry through
  FUN_FILE_READ, and writes it out with Win32.
- New generic Game::Console facility (RegisterCommand / Write / Category
  enum) wrapping the engine's console-command registrar and output
  paths, so future commands register in one line.
- Offsets.h: FUN_MPQ_ENUM_FILES, FUN_CONSOLE_COMMAND_REGISTER,
  FUN_CONSOLE_WRITE; corrected FUN_FILE_READ to __stdcall and documented
  its archive-handle arg0.
- Docs: README "Console commands" section + docs/API.md "Console" entry.
2026-06-22 21:51:42 -05:00
Brues 3b729f08cb cleanup 2026-06-22 20:57:42 -05:00
Brues cdb3faa982 caching player guid 2026-06-22 18:55:20 -05:00
Brues bb79311556 add github repo to toc 2026-06-22 15:16:19 -05:00
Brues e881e18720 Fire PLAYER_STARTED_MOVING for both-mouse-button run
Holding both mouse buttons to run forward never fired
PLAYER_STARTED_MOVING. Ghidra trace of the input button handler
(FUN_00514840) shows that gesture drives the forward move directly at
the movement layer (FUN_005103e0) and explicitly clears the autorun
bit on the both-button press — so it sets none of the
INPUT_FLAGS_MOVING_ANY bits the mover-detection reads. (The Offsets.h
comment claiming the both-button combo sets autorun was wrong;
corrected.)

Detect it from the bit combo instead: both INPUT_FLAG_MOUSELOOK (RMB)
and INPUT_FLAG_FREE_LOOK (LMB) held means the engine is mouse-steering
the character forward. ORed into `moving`. Composes cleanly with the
TURNING/LOOKING latches (unchanged).
2026-06-22 12:51:30 -05:00
Brues ba4520493a Stop clobbering native FrameXML time globals; sort enUS locale
Stock 1.12.1 already defines DAYS_ABBR/HOURS_ABBR/MINUTES_ABBR/
SECONDS_ABBR (bare labels: "Day"/"Hr"/"Min"/"Sec") and the four
*_ONELETTER_ABBR. Our enUS.lua loads after FrameXML, so redefining
them with the modern "%d |4Day:Days;" format silently overwrote the
native values and broke any native/other-addon code reading the bare
labels.

Remove those redefinitions from enUS.lua (keep only the strings stock
genuinely lacks), and drop SecondsToTime / SecondsToTimeAbbrev /
FormatUnit from TimeUtil — they were the only consumers of the
modern-format _ABBR strings. SecondsToClock stays (uses the
stock-missing HOURS_MINUTES_SECONDS / MINUTES_SECONDS). Remaining
enUS entries sorted, all still have live consumers.
2026-06-22 12:19:26 -05:00
Brues dc80ade21b Add native GetPhysicalScreenSize + PixelUtil backport
`GetPhysicalScreenSize()` → (widthPixels, heightPixels). Modern WoW
added this native in 7.0; FrameXML's PixelUtil builds its entire
pixel↔UI-unit conversion on it (768/physicalHeight). 1.12 lacks it and
has no pre-parsed pixel-dimension global — GetScreenWidth/Height are
UI-space (UIParent aspect at [UIParent+0x7c]), and the engine only
holds the current mode as the gxResolution CVar string, sscanf'd on
demand by SetScreenResolution / display-init. So the native reads the
gxResolution CVar directly (FUN_FIND_CVAR, value at +0x20) and parses
"WIDTHxHEIGHT" — the same source pfUI's GetPerfectPixel uses. Verified
in-game: 2560x1080.

7.0 was the only binary exposing the function, but retail .text is
encrypted on disk so static decompilation yields garbage; derived the
implementation from the 1.12 binary instead.

Also backports FrameXML/PixelUtil.lua (SetPoint/SetSize/SetWidth/
SetHeight/SetStatusBarValue/Get*). Its math deps (Round/Lerp/
ClampedPercentageBetween) already live in MathUtil; it consumes the
native GetPhysicalScreenSize (no Lua polyfill).
2026-06-22 00:04:17 -05:00
Brues 6addd6d5b1 Add CastAtUnit / UseAtUnit — ground-target placement at a unit's feet
`C_Spell.CastAtUnit(spellID, unit)` and `C_Item.UseAtUnit(itemInfo,
unit)` are the unit-position analogs of the existing `…AtCursor` pair:
they commit the engine's in-flight ground-target placement at the
resolved unit's world position instead of the cursor raycast.
ClassicAPI's `/cast [@unit]` / `[@unit]` for AoE reticle spells and
on-use items. Verified in-game against "player", "target", and
"nameplate1".

Implementation reuses the cursor path's placement primitives:
- Extracted `Spell::AtCursor::DispatchSpellCast` + `CommitAtCoords`
  (explicit-coords commit, sharing the same placement-ready gate as
  the cursor `Resolve`).
- New header-only `Unit::Position` (vtable slot-5 `GetPosition` read);
  migrated `UnitInRange` onto it to drop the duplicate copy.

The unit is resolved before the cast/use is dispatched, so an absent
unit fails cleanly without arming placement mode or consuming the
item. Unknown token strings raise the engine's standard "Unknown unit"
error, matching `UnitHealth("garbage")`.
2026-06-21 23:36:19 -05:00
Brues 2576eb424e cleanup 2026-06-21 18:19:16 -05:00
Brues d80d87e28b Move FrameXML format-string globals to the locale layer
Source the strings FormattingUtil/TimeUtil consume (LARGE_NUMBER_SEPERATOR,
PERCENTAGE_STRING, GOLD/SILVER/COPPER_AMOUNT_SYMBOL, the time
abbreviations + D_* + *_ONELETTER_ABBR, clock formats) from Classic Era
enUS GlobalStrings and define them in locales/enUS.lua, which loads
before the Util files. `or`-guarded so any value the 1.12 client already
defines is preserved (the time abbrevs use |4 plural tokens, which 1.12
supports — our existing ITEMS_* strings already rely on |4).

The Util files no longer define these (single source of truth in the
locale layer); they retain only the non-locale numeric constants
(COPPER_PER_*, SECONDS_PER_*) and the formatting logic. FormatUnit still
inserts the count whether the unit string carries a %d or is a bare label.
2026-06-21 18:17:17 -05:00
Brues a0ebfe99b4 TimeUtil: fix SecondsToTime dropping the unit count
1.12's native unit-abbreviation globals (HOURS_ABBR, MINUTES_ABBR, …)
are bare labels ("Hr", "Min") with no %d placeholder, unlike modern's
"%d Hr". So string.format(HOURS_ABBR, n) returned just "Hr" — the count
was lost (SecondsToTime(3661) -> "Hr Min" instead of "1 Hr 1 Min").

Add a FormatUnit helper that string.formats when the unit string has a
%d and otherwise prepends "<count> ", so it's correct whether the global
is modern ("%d Hr") or a bare 1.12 label ("Hr"). Verified expectation:
SecondsToTime(3661) -> "1 Hr 1 Min".
2026-06-21 18:05:40 -05:00
Brues 2bc624c271 Backport FrameUtil / FormattingUtil / TimeUtil (FrameXML utils)
Proactive Tier-1 backports of the most widely-assumed FrameXML utility
globals, ported to Lua 5.0 (no `#`/`%`/string-method syntax; `%` ->
math.mod). Feasible subsets only:

- FrameUtil: RegisterFrameForEvents / UnregisterFrameForEvents,
  GetRootParent, SetParentMaintainRenderLayering, DoesAncestryInclude(Any).
  Omits the fade/flash helpers (native in 1.12) and the scale/layout/
  secure/RegisterUnitEvent helpers (APIs absent here).
- FormattingUtil: FormatLargeNumber, FormatPercentage, FormatFraction,
  SplitTextIntoLines, SplitTextIntoHeaderAndNonHeader, GetMoneyString.
  GetMoneyString uses text symbols (g/s/c) since coin texture markup
  doesn't render in 1.12; currency/cost helpers omitted.
- TimeUtil: SecondsToTime, SecondsToClock, SecondsToTimeAbbrev,
  ConvertSecondsToUnits, SecondsToMinutes/MinutesToSeconds + unit
  constants. Omits the modern SecondsFormatterMixin.

FrameXML format-string globals (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING,
DAYS_ABBR, GOLD_AMOUNT_SYMBOL, …) are defined with `or` fallbacks so a
native/localized value wins when present and we degrade sanely otherwise.
Loaded after MathUtil so FormatPercentage's Round() is available.
2026-06-21 17:43:06 -05:00
Brues 39608efe75 correct aoe name binding var name 2026-06-21 17:28:18 -05:00
Brues af1d6d25a4 Fix color-table load order in the TOC, keep tables in Constants
Reverts the relocate-to-ColorUtil approach (b2db474): PLAYER_FACTION_COLORS
and BAG_ITEM_QUALITY_COLORS belong in Constants.lua, matching Blizzard's
own layout (its FrameXMLBase/Constants.lua references the named *_COLOR
objects directly).

Blizzard gets away with it via load order: the color objects are
materialized before its Constants loads. We now do the same — move
Mixin.lua + Color.lua above Constants.lua in the TOC, so Color.lua's
C_UIColor loop creates COMMON_GRAY_COLOR / PLAYER_FACTION_COLOR_* before
Constants.lua references them. Safe because Color.lua only depends on
Mixin.lua (+ the native ITEM_QUALITY_COLORS and the DLL's C_UIColor),
nothing else in the load chain; Constants.lua doesn't depend on the
utils that now follow it.
2026-06-21 17:23:51 -05:00
Brues 5efe47cc34 Fix empty PLAYER_FACTION_COLORS (load-order bug)
PLAYER_FACTION_COLORS was built in Constants.lua (loads early), but it
references the PLAYER_FACTION_COLOR_HORDE/ALLIANCE color objects that
Color.lua only materializes from C_UIColor at a later load step. So the
table captured nils -> ended up {}, and GetFactionColor always returned
nil. (Can't just reorder the TOC: Color.lua depends on ITEM_QUALITY_COLORS
from Constants.lua, so it's circular.)

Move the PLAYER_FACTION_COLORS construction to ColorUtil.lua, which loads
after Color.lua (and is where its only consumer, GetFactionColor, lives),
so the color objects exist by then. PLAYER_FACTION_GROUP stays in
Constants.lua (no such dependency).
2026-06-21 17:14:16 -05:00
Brues aacbc0d92c added missing faction globals 2026-06-21 17:11:21 -05:00
Brues 241a5befd2 Constants: define PLAYER_FACTION_GROUP (tag -> index map)
GetFactionColor needs the FrameXML PLAYER_FACTION_GROUP global (faction
tag -> 0/1 index) to pair with our PLAYER_FACTION_COLORS, but we only
shipped the latter, so the lookup always returned nil. Add
PLAYER_FACTION_GROUP next to it, matching Blizzard SharedConstants
({[0]="Horde",[1]="Alliance",Horde=0,Alliance=1}); `or`-guarded so a
native definition wins if present.
2026-06-21 17:10:56 -05:00
Brues edab1d1e2a Color/ColorUtil: backport remaining FrameXML helpers
Fills the gaps found diffing against Blizzard's reference (same class of
missing-global that broke tContains), porting to Lua 5.0:

Color.lua:
  - WrapTextInColor(text, color)
  - ColorMixin:IsRGBEqualTo (IsEqualTo now routes through it, per ref)
  - ColorMixin:GetHSL
  - ColorMixin:GenerateHexColorNoAlpha

ColorUtil.lua:
  - CreateColorFromRGBHexString / CreateColorFromRGBAHexString
  - IsRGBAEqualToColor
  - RGBToColorCode / RGBTableToColorCode
  - GetFactionColor (guarded: PLAYER_FACTION_GROUP/COLORS may be absent)

Color.lua and ColorUtil.lua now match the reference's function set. The
deliberately-skipped Secure* variants in Mixin/Pools (modern secure-
template machinery with no 1.12 equivalent) are left out.
2026-06-21 17:02:20 -05:00
Brues b55424bab6 TableUtil: fix tDeleteItem for Lua 5.0
tDeleteItem (added in 11191e8) used the 5.1 length operator `#tbl`, which
doesn't exist in 1.12's Lua 5.0. Swap both occurrences to tGetN(tbl)
(table.getn) to match the rest of the file.
2026-06-21 16:49:01 -05:00
Brues 11191e8d4c Added tContains and tDeleteItem 2026-06-21 16:44:49 -05:00
Brues 4263bae064 UnitChannelInfo: effective channel duration via engine helper
Channel end time read SpellDuration.dbc base directly; route it through
the engine's own duration helper FUN_006ea000 (base + level scaling +
duration SpellMod op 1, unit arg 0 = local player) — mirroring how the
cast time is sourced, so the channel bar matches the engine and any
per-level / talent duration is honored. Stock vanilla channels are
fixed-duration, so no change there (verified: Blizzard still 8s).

Completes the SpellMod op map derived this session: duration 1, range 5,
radius 6, cast time 10, cost 14.
2026-06-21 16:32:12 -05:00
Brues 6f928575fa Share PowerTypeToken via Unit::Power header
GetSpellPowerCost had a duplicate power-type -> token map. Promote the
existing UnitPowerType mapping out of Power.cpp's anonymous namespace
into a shared unit/Power.h (Unit::Power::PowerTypeToken) and have both
consumers use it. Out-of-range PowerType now reports "UNKNOWN" in
GetSpellPowerCost (was ""), matching UnitPowerType.
2026-06-21 16:26:39 -05:00
Brues 1aeafd41a7 IsUsableSpell: gate on effective mana cost, not base
The mana check read the base ManaCost (+0x80) directly, so a spell with
a cost-reduction talent was wrongly reported as unusable (noMana) when
the player's power sat between the reduced and base cost. Route it
through the engine's effective-cost helper FUN_006e31b0 (op-14 SpellMod +
descriptor power-cost mods + ManaCostPercent), with a base-cost fallback
if the engine can't resolve a cost.

Example: Blizzard base 1400, 3/3 Frost Channeling -> effective 1190; with
~1200 power the spell is now correctly usable instead of noMana.
2026-06-21 16:26:29 -05:00
Brues 3003b1129e Add C_Spell.GetSpellPowerCost(spellIdentifier)
Returns a SpellPowerCostInfo[] (one entry in vanilla — spells have a
single power cost), or nil for an unknown / no-cost spell. The `cost` is
the player-effective value via the engine's own cost helper FUN_006e31b0
(base + level + ManaCostPercent-of-resource + descriptor power-cost mods
+ the cost SpellMod, op 0xE) — so it matches what casting actually
deducts, talents included.

Verified in-game: Blizzard (10187) on a Mage with 3/3 Frost Channeling
returns cost 1190 = 1400 base x 0.85 (15% reduction).

Keeps C_Spell.GetSpellInfo's `cost` field as the base ManaCost (intrinsic
spell data); GetSpellPowerCost is the player-modified counterpart — same
base/modified split as GetSpellRadius. Calling the full engine helper
beats replicating op 14 via Spell::Mod, since the helper also folds in
the descriptor power-cost modifiers.

Fields: type (Enum.PowerType), name (power token), cost, minCost (==cost,
no optional cost in vanilla), costPercent, costPerSec 0, requiredAuraID 0,
hasRequiredAura false.

Docs: API.md section + README Spell row.
2026-06-21 16:20:42 -05:00
Brues bf9250141a Add UnitCastingInfo / CastingInfo / UnitChannelInfo / ChannelInfo (TODO #68)
Cast/channel info for the local player with full timing, plus channel
spellID/name/texture for any unit. Vanilla stores no cast times anywhere
readable (confirmed via the cross-binary technique: 3.3.5 reads them from
per-unit CGUnit fields that don't exist in 1.12), so the player's
cast/channel start/end are self-tracked on WorldTick:

- cast: VAR_CURRENT_CAST_SPELL 0->nonzero transition stamps startMs = now
  (FUN_OS_TICKCOUNT_MS) and endMs = now + effective cast time. The cast
  time comes from the engine's own helper FUN_006e3340 (base + level
  scaling + cast-time SpellMod op 10 + the caster's haste/cast-speed
  multiplier), the same one the engine cast-start uses -- so end time
  matches the cast bar, e.g. a talented Frostbolt reads 2.5s not 3.0s.
  Instant casts (cast time 0) and channels are filtered out.
- channel: player's broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228),
  endMs = now + SpellDuration base ms.

FUN_OS_TICKCOUNT_MS shares GetTime()'s epoch (x0.001), so startMs/endMs
feed addon progress math directly. Other units: channel info from the
broadcast +0x228 field (no times, since per-unit start isn't tracked);
regular casts return nil (no vanilla data -- remote casts are transient
spell-visual objects, not a queryable record).

isTradeskill is real: the spell's SPELL_ATTR_TRADESPELL flag (Attributes
bit 0x20). Remaining placeholders: castID/castBarID nil, notInterruptible
false, delayTimeMs 0.

Verified in-game: Blizzard channel 8s, Conjure Food cast 3s, talented
Frostbolt 2.5s, correct icons/spellIDs, GetTime()*1000 between start/end;
isTradeskill true for Rough Blasting Powder (Engineering).

Docs: API.md sections + README Spell row; TODO #68 marked done.
2026-06-21 16:05:45 -05:00
Brues 3df21247a2 Add Spell::Mod (talent/item SpellMod) + GetSpellRadius modified return
GetSpellRadius now returns (baseRadius, modifiedRadius). modifiedRadius
applies the local player's talent/item radius modifiers; equal to base
when no modifier matches.

The modifier logic is extracted into a reusable Spell::Mod helper
(src/spell/Mod.{h,cpp}) since the same engine SpellMod mechanism drives
cast time, mana cost, duration, range, cooldown, etc. — only the op
differs. Spell::Mod::Apply(spellRecord, op, base) replicates
FUN_006e6b30/FUN_006e6bf0: gate on the spell's SpellFamilyName matching
the player's class family (ChrClasses.dbc[class]+0x3c), sum the player's
flat/percent tables (0x00CEAD60 / 0x00CECB30, stride 0x74) over the
spell's 64-bit SpellFamilyFlags, then (base + flat) * (100 + pct) * 0.01.
Inherently local-player-only — the client tracks spell mods for the
player alone.

Offsets: VAR_SPELLMOD_FLAT/PCT_TABLE, SPELLMOD_SLOT_STRIDE/COUNT,
SPELLMOD_OP_RADIUS, OFF_SPELL_RECORD_FAMILY_NAME/FLAGS/ATTRIBUTES_EX2,
SPELL_ATTR_EX2_NO_SPELL_MODS, OFF_CHRCLASSES_SPELL_FAMILY.

Verified in-game: Frost Nova (10230) -> (10, 10) without Arctic Reach,
(10, 12) with rank 2 (+20%). op 6 = radius confirmed via FUN_006e6350;
percent constant 0.01 confirmed from .rdata.
2026-06-21 15:01:58 -05:00
Brues 3b33a926ed Add GetSpellRadius (TODO #38)
C_Spell.GetSpellRadius(spellID) plus the vanilla-positional
GetSpellRadius(slot, bookType). Returns the spell's AOE radius in yards,
or nil for a non-AOE spell.

Radius is per-effect: Spell.dbc EffectRadiusIndex[3] at record +0x160
(verified via FUN_006e6350, which reads spellRec[+0x160]/[+0x164] for
effects 0/1), each resolving into SpellRadius.dbc (records 0x00C0D7B0,
count 0x00C0D7B4; base radius f32 at record +0x04). Returns the largest
base radius across the three effects. Base radius only — no caster-level
scaling, matching modern GetSpellRadius.

Dual signature mirrors SpellHasRange/GetSpellInfo: namespaced form takes
a spell identifier, the bare global takes a (slot, bookType) spellbook
position (bookType "pet" -> pet book, else player book).

Docs: API.md section + README Spell row; TODO #38 marked done.
2026-06-21 14:44:04 -05:00
Brues dfddf1698d Document GetSpellMechanicByID: list all 27 SpellMechanic.dbc rows
Replace the common-CC subset with the full SpellMechanic.dbc table
(ID + name) so the doc enumerates every value the function can return.
2026-06-21 13:54:57 -05:00
Brues 6308429b26 Document C_Spell.GetSpellMechanicByID
Add the API.md section (return contract, always-English rationale,
common mechanic IDs + the Sap/Gouge=14 vs nonexistent 30 caveat) and
the README Spell-row entry.
2026-06-21 13:53:48 -05:00
Brues 37e8b3242c Add C_Spell.GetSpellMechanicByID(spellID) -> (mechanicID, name)
Reads the spell-level Mechanic field straight out of Spell.dbc
(record +0x14) and resolves the name from SpellMechanic.dbc. Covers
every spell the client knows (not just the player's spellbook), with no
caching or network round-trip since the DBCs are resident from boot.
Replaces hand-maintained spellID -> mechanic tables addons keep because
vanilla exposes no Lua reader for the field (e.g. SuperCleveRoidMacros'
CCSpellMechanics, used for [cc:stun] conditionals).

Returns: nil for an invalid spellID; (0, nil) for a known spell with no
mechanic; (mechanicID, name) otherwise. The name is always the enUS
column (locale-independent) since it's a stable token consumers
string-match against; nil if the mechanic has no record / no English
name (numeric ID still returned).

Mechanic offset (+0x14) verified two ways: schema anchor off the
verified Attributes@+0x18, and FUN_006e9ca0 (mechanic-immunity check)
reading spellRec[+0x14] as the spell's mechanic. SpellMechanic.dbc name
at record +0x04 confirmed in-game: 118 -> (17,"polymorphed"),
339 -> (7,"rooted"), 133 -> (0,nil). The .dbc has 27 rows; only the
enUS name column is populated.
2026-06-21 13:50:45 -05:00
Brues bdc574d6ce slash commands are localized now 2026-06-21 01:43:08 -05:00
Brues 85e3f3c051 add /focus and /clearfocus 2026-06-21 01:31:14 -05:00
Brues ee2b4d22ac GetItemInfo failure fix
commit 52064f025188267ab44dae41af93261ae1bcfc03
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 18 23:33:09 2026 -0500

    Item cache: fast-fail server rejections; report pending overflow

    Two follow-ups to the WarmCache startup-latch fix.

    Failure fast-path: the escalation path used a no-op callback, so a
    server "no such item" reply (high-bit itemID — the FUN_0055BDB0 failure
    branch does NOT set the entry loaded flag) went undetected and the item
    waited out the full kMaxWaitTicks (~20s) before reporting failure.
    Replace it with ItemLoadCallback_Escalated, which marks the pending
    entry failed when the engine reports !success. A shared TryCompletePending
    helper resolves each entry either way (loaded -> success, failed ->
    failure) and is now used by both the response-handler sweep and the
    tick, so rejections fire in the same response. Genuinely lost queries
    (server silent) still fall back to the timeout.

    Overflow: Track now returns false when the pending set is full instead
    of silently dropping, and the explicit RequestLoadItemData/ByID Lua
    functions propagate that as their boolean return. Bump kMaxPending
    256 -> 512 since the unsettled window now also holds implicit warmups.

commit 797a73de44e6a4ceb0ac434944a137dcd47c18f3
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 18 23:24:04 2026 -0500

    Item cache: gate WarmCache behind a startup-settled latch

    The previous fix (ad13440) deferred only the explicit
    RequestLoadItemData path out of the engine startup window. The implicit
    WarmCache path — the GetItemInfo hook plus every GetItem* getter cache-
    miss warmup — still eagerly called CacheFetch(callback) with no gate, so
    it orphaned cache entries during the exact PLAYER_ENTERING_WORLD /
    addon-load window the original bug was about. pfQuest/Questie scan item
    data there, so those items stayed permanently nil for the session.

    WarmCache now gates on a one-way g_settled latch:
    - Pre-settle (startup): defer through the same pending set as
      RequestLoadItemData, tagged implicit so the response-handler sweep
      fires GET_ITEM_INFO_RECEIVED (not ITEM_DATA_LOAD_RESULT).
    - Post-settle: eager CacheFetch as before, for low-latency mid-session
      lookups.

    The latch flips true the moment the engine response handler fires (proof
    dispatch is live) or after kSettleTicks world ticks, whichever first.
    Per-process, not re-armed on relog (cold-WDB race is initial-login only).

    Unify both paths through Track(itemID, implicit); sweep and timeout fire
    the correct event by tag. Escalation now gates on g_settled instead of a
    fixed per-item tick count, draining the startup backlog as soon as it is
    safe. Bump kMaxPending 64 -> 256 to absorb the larger startup burst.
2026-06-18 23:34:37 -05:00
Brues 59a603f9b2 AoE Loot binding 2026-06-18 23:23:26 -05:00
Brues 0332e49ac2 UnitNameFromGUID: resolve via object manager + FUN_OBJECT_GET_NAME
The original implementation only consulted the SMSG_NAME_QUERY_RESPONSE-
fed player NameCache, which only populates for GUIDs the engine has
explicitly queried — so currently-synced players (just-targeted, in
view, etc.) typically miss until the engine sends a name query.

Switch to vanilla's own `Script_UnitName` path: resolve the GUID via
`FUN_CLNT_OBJ_MGR_OBJECT_PTR` with TYPEMASK_OBJECT, then call the
engine's polymorphic name getter `FUN_OBJECT_GET_NAME`. That routes
through the player NameCache AND the creature cache, so both player
and NPC GUIDs return real names for any currently-synced unit. Gate
on the "UNKNOWNOBJECT" / "Unknown Being" sentinels so non-unit
objects fall through. Persistent NameCache fallback kept for ex-units
that have left the engine's sync window.
2026-06-17 20:52:30 -05:00
Brues b1b564a533 Add UnitSubName(unit) — NPC subtitle / title text
Modern WoW returns the subtitle as UnitName's second return; vanilla
1.12's UnitName returns only one value, leaving no direct route to
the subtitle. UnitSubName reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip scrape required.

Returns nil for players, unsynced NPCs, and NPCs with no subtitle.
2026-06-17 20:34:53 -05:00
Brues 444875ae38 Add UnitNameFromGUID(guid) → (name, realm)
Modern backport that reuses GetPlayerInfoByGUID's lookup chain
(engine NameCache → persistent on-disk fallback) but pushes only
(name, realm) — narrower return shape for callers that only need
the name. realm is always "" in vanilla (single-realm, engine
doesn't populate per-player realm).
2026-06-17 20:16:25 -05:00
56 changed files with 5160 additions and 355 deletions
+7 -2
View File
@@ -3,19 +3,24 @@
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
locales\locales.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\String.lua
Util\Mixin.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
Util\FrameUtil.lua
Util\FormattingUtil.lua
Util\TimeUtil.lua
Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\Color.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\EventUtil.lua
+42 -2
View File
@@ -10,11 +10,14 @@ function ColorMixin:OnLoad(r, g, b, a)
self:SetRGBA(r, g, b, a)
end
function ColorMixin:IsEqualTo(otherColor)
function ColorMixin:IsRGBEqualTo(otherColor)
return self.r == otherColor.r
and self.g == otherColor.g
and self.b == otherColor.b
and self.a == otherColor.a
end
function ColorMixin:IsEqualTo(otherColor)
return self:IsRGBEqualTo(otherColor) and self.a == otherColor.a
end
function ColorMixin:GetRGB()
@@ -52,6 +55,39 @@ 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())
end
@@ -60,6 +96,10 @@ function WrapTextInColorCode(text, colorHexString)
return string.format("|c%s%s|r", colorHexString, text)
end
function WrapTextInColor(text, color)
return WrapTextInColorCode(text, color:GenerateHexColor())
end
function ColorMixin:WrapTextInColorTableCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
end
+39 -1
View File
@@ -20,6 +20,24 @@ function CreateColorFromHexString(hexColor)
end
end
function CreateColorFromRGBAHexString(hexColor)
if string.len(hexColor) == 8 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
end
function CreateColorFromRGBHexString(hexColor)
if string.len(hexColor) == 6 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
end
function CreateColorFromBytes(r, g, b, a)
return CreateColor(r / 255, g / 255, b / 255, a / 255);
end
@@ -31,6 +49,10 @@ function AreColorsEqual(left, right)
return left == right;
end
function IsRGBAEqualToColor(r, g, b, a, color)
return (color.r == r) and (color.g == g) and (color.b == b) and (color.a == a);
end
function GetClassColor(classFilename)
local color = RAID_CLASS_COLORS[classFilename];
if color then
@@ -46,7 +68,23 @@ end
function GetClassColoredTextForUnit(unit, text)
local _, classFilename = UnitClass(unit);
local color = GetClassColorObj(classFilename);
if (color) then
if (color) then
return color:WrapTextInColorCode(text);
end
end
function GetFactionColor(factionGroupTag)
-- Both PLAYER_FACTION_GROUP (tag -> index) and PLAYER_FACTION_COLORS
-- (index -> color) live in our Constants.lua. Guarded defensively in
-- case a client variant leaves one unset.
local group = PLAYER_FACTION_GROUP[factionGroupTag];
return PLAYER_FACTION_COLORS and PLAYER_FACTION_COLORS[group];
end
function RGBToColorCode(r, g, b)
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
end
function RGBTableToColorCode(rgbTable)
return RGBToColorCode(rgbTable.r, rgbTable.g, rgbTable.b);
end
+13 -1
View File
@@ -106,8 +106,20 @@ BAG_ITEM_QUALITY_COLORS =
};
-- faction
PLAYER_FACTION_GROUP = { [0] = "Horde", [1] = "Alliance", Horde = 0, Alliance = 1 };
FACTION_LABELS = {
[0] = FACTION_HORDE,
[1] = FACTION_ALLIANCE,
};
FACTION_LABELS_FROM_STRING = {
["Horde"] = FACTION_HORDE,
["Alliance"] = FACTION_ALLIANCE,
}
PLAYER_FACTION_COLORS =
{
{
[0] = PLAYER_FACTION_COLOR_HORDE,
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};
@@ -0,0 +1,108 @@
-- Backport of FrameXML/FormattingUtil.lua (feasible subset) to vanilla
-- 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
-- defined in the locale layer (locales/enUS.lua), which loads first.
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD;
function FormatLargeNumber(amount)
amount = tostring(amount);
local newDisplay = "";
local strlen = string.len(amount);
-- Insert a separator before each group of three trailing digits.
for i = 4, strlen, 3 do
newDisplay = LARGE_NUMBER_SEPERATOR ..
string.sub(amount, -(i - 1), -(i - 3)) .. newDisplay;
end
-- Prepend everything before the first separator.
local lead = (math.mod(strlen, 3) == 0) and 3 or math.mod(strlen, 3);
newDisplay = string.sub(amount, 1, lead) .. newDisplay;
return newDisplay;
end
function FormatPercentage(percentage, roundToNearestInteger)
if roundToNearestInteger then
percentage = Round(percentage * 100);
else
percentage = percentage * 100;
end
return string.format(PERCENTAGE_STRING, percentage);
end
function FormatFraction(numerator, denominator)
return string.format(GENERIC_FRACTION_STRING, numerator, denominator);
end
function SplitTextIntoLines(text, delimiter)
local lines = {};
local startIndex = 1;
local foundIndex = string.find(text, delimiter);
while foundIndex do
table.insert(lines, string.sub(text, startIndex, foundIndex - 1));
startIndex = foundIndex + 2;
foundIndex = string.find(text, delimiter, startIndex);
end
if startIndex <= string.len(text) then
table.insert(lines, string.sub(text, startIndex));
end
return lines;
end
function SplitTextIntoHeaderAndNonHeader(text)
local foundIndex = string.find(text, "|n");
local len = string.len(text);
if not foundIndex then
return text; -- no newline: whole thing is a header
elseif len == 2 then
return nil; -- the string was just a newline
elseif foundIndex == 1 then
return string.sub(text, 3); -- leading newline: rest is a header
elseif foundIndex == len - 1 then
return string.sub(text, 1, foundIndex - 1); -- trailing newline
else
return string.sub(text, 1, foundIndex - 1), string.sub(text, foundIndex + 2);
end
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
-- reference is dropped since texture markup doesn't render in 1.12. The
-- checkGoldThreshold arg is accepted for signature compatibility but not
-- applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
local copper = math.mod(money, COPPER_PER_SILVER);
local goldString = (separateThousands and FormatLargeNumber(gold) or tostring(gold)) ..
GOLD_AMOUNT_SYMBOL;
local silverString = silver .. SILVER_AMOUNT_SYMBOL;
local copperString = copper .. COPPER_AMOUNT_SYMBOL;
if showZeroAsGold and money == 0 then
return goldString;
end
local moneyString = "";
local separator = "";
if gold > 0 then
moneyString = goldString;
separator = " ";
end
if silver > 0 then
moneyString = moneyString .. separator .. silverString;
separator = " ";
end
if copper > 0 or moneyString == "" then
moneyString = moneyString .. separator .. copperString;
end
return moneyString;
end
+64
View File
@@ -0,0 +1,64 @@
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
--
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
FrameUtil = FrameUtil or {};
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
end
function FrameUtil.GetRootParent(frame)
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
end
-- Reparents while preserving the frame's render layering.
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
end
function DoesAncestryInclude(ancestry, frame)
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
end
function DoesAncestryIncludeAny(ancestry, frames)
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
end
+86
View File
@@ -0,0 +1,86 @@
-- Backport of FrameXML/PixelUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- PixelUtil snaps frame sizes/points to whole physical pixels so
-- borders and textures stay crisp at fractional UI scales. The table
-- ports verbatim — its only math deps (Round, Lerp,
-- ClampedPercentageBetween) all live in MathUtil, which loads first.
--
-- The one piece vanilla lacks is the native `GetPhysicalScreenSize()`
-- (added in 7.0). The ClassicAPI DLL provides it (see
-- src/screen/Info.cpp) by reading the engine's `gxResolution` CVar, so
-- it's a real global registered at engine boot — no Lua polyfill
-- needed here.
PixelUtil = {};
function PixelUtil.GetPixelToUIUnitFactor()
local physicalWidth, physicalHeight = GetPhysicalScreenSize();
return 768.0 / physicalHeight;
end
function PixelUtil.GetNearestPixelSize(uiUnitSize, layoutScale, minPixels)
if uiUnitSize == 0 and (not minPixels or minPixels == 0) then
return 0;
end
local uiUnitFactor = PixelUtil.GetPixelToUIUnitFactor();
local numPixels = Round((uiUnitSize * layoutScale) / uiUnitFactor);
if minPixels then
if uiUnitSize < 0.0 then
if numPixels > -minPixels then
numPixels = -minPixels;
end
else
if numPixels < minPixels then
numPixels = minPixels;
end
end
end
return numPixels * uiUnitFactor / layoutScale;
end
function PixelUtil.ConvertPixelsToUI(desiredPixels, layoutScale)
return PixelUtil.GetNearestPixelSize(desiredPixels, layoutScale);
end
function PixelUtil.ConvertPixelsToUIForRegion(desiredPixels, region)
return PixelUtil.GetNearestPixelSize(desiredPixels, region:GetEffectiveScale());
end
function PixelUtil.SetWidth(region, width, minPixels)
region:SetWidth(PixelUtil.GetNearestPixelSize(width, region:GetEffectiveScale(), minPixels));
end
function PixelUtil.SetHeight(region, height, minPixels)
region:SetHeight(PixelUtil.GetNearestPixelSize(height, region:GetEffectiveScale(), minPixels));
end
function PixelUtil.SetSize(region, width, height, minWidthPixels, minHeightPixels)
PixelUtil.SetWidth(region, width, minWidthPixels);
PixelUtil.SetHeight(region, height, minHeightPixels);
end
function PixelUtil.SetPoint(region, point, relativeTo, relativePoint, offsetX, offsetY, minOffsetXPixels, minOffsetYPixels)
region:SetPoint(point, relativeTo, relativePoint,
PixelUtil.GetNearestPixelSize(offsetX, region:GetEffectiveScale(), minOffsetXPixels),
PixelUtil.GetNearestPixelSize(offsetY, region:GetEffectiveScale(), minOffsetYPixels)
);
end
function PixelUtil.SetStatusBarValue(statusBar, value)
local width = statusBar:GetWidth();
if width and width > 0.0 then
local min, max = statusBar:GetMinMaxValues();
local percent = ClampedPercentageBetween(value, min, max);
if percent == 0.0 or percent == 1.0 then
statusBar:SetValue(value);
else
local numPixels = PixelUtil.GetNearestPixelSize(statusBar:GetWidth() * percent, statusBar:GetEffectiveScale());
local roundedValue = Lerp(min, max, numPixels / width);
statusBar:SetValue(roundedValue);
end
else
statusBar:SetValue(value);
end
end
+6 -2
View File
@@ -32,8 +32,12 @@ function PlayerUtil.GetCurrentSpeed()
return GetUnitSpeed("player") / 7 * 100
end
local playerGuid
function GetPlayerGuid()
return UnitGUID("player");
if not playerGuid then
playerGuid = UnitGUID("player")
end
return playerGuid
end
function IsPlayerGuid(guid)
@@ -43,4 +47,4 @@ end
function GetNameAndServerNameFromGUID(unitGUID)
local _, _, _, _, _, name = GetPlayerInfoByGUID(unitGUID);
return name, GetRealmName()
end
end
@@ -4,4 +4,22 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
_G["SLASH_"..name.."1"] = "/"..command;
_G["SLASH_"..name.."2"] = "/"..commandAlias;
SlashCmdList[name] = callback;
end
end
SlashCmdList["FOCUS"] = function(msg)
if msg == "" then
FocusUnit();
else
FocusUnit(msg);
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
end
SlashCmdList["EQUIP_SET"] = function(msg)
if msg ~= "" then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
end
end
+21
View File
@@ -65,6 +65,18 @@ function CreateTableReverseEnumerator(tbl, minIndex, maxIndex)
return Enumerator, tbl, maxIndex
end
function tDeleteItem(tbl, item)
local size = tGetN(tbl);
local index = size;
while index > 0 do
if item == tbl[index] then
tRemove(tbl, index);
end
index = index - 1;
end
return size - tGetN(tbl);
end
function tIndexOf(tbl, item)
for i, v in ipairs(tbl) do
if item == v then
@@ -73,6 +85,15 @@ function tIndexOf(tbl, item)
end
end
function tContains(tbl, item)
for k, v in pairs(tbl) do
if item == v then
return true;
end
end
return false;
end
function TableUtil.ContainsAllKeys(lhsTable, rhsTable)
for key, _ in pairs(lhsTable) do
if rhsTable[key] == nil then
+41
View File
@@ -0,0 +1,41 @@
SECONDS_PER_MIN = 60;
SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN;
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY;
SECONDS_PER_YEAR = 12 * SECONDS_PER_MONTH;
function SecondsToMinutes(seconds)
return seconds / SECONDS_PER_MIN;
end
function MinutesToSeconds(minutes)
return minutes * SECONDS_PER_MIN;
end
function ConvertSecondsToUnits(timestamp)
timestamp = math.max(timestamp, 0);
local days = math.floor(timestamp / SECONDS_PER_DAY);
timestamp = timestamp - (days * SECONDS_PER_DAY);
local hours = math.floor(timestamp / SECONDS_PER_HOUR);
timestamp = timestamp - (hours * SECONDS_PER_HOUR);
local minutes = math.floor(timestamp / SECONDS_PER_MIN);
timestamp = timestamp - (minutes * SECONDS_PER_MIN);
local seconds = math.floor(timestamp);
local milliseconds = timestamp - seconds;
return {
days = days,
hours = hours,
minutes = minutes,
seconds = seconds,
milliseconds = milliseconds,
};
end
function SecondsToClock(seconds, displayZeroHours)
local units = ConvertSecondsToUnits(seconds);
if units.hours > 0 or displayZeroHours then
return string.format(HOURS_MINUTES_SECONDS, units.hours, units.minutes, units.seconds);
else
return string.format(MINUTES_SECONDS, units.minutes, units.seconds);
end
end
+18 -3
View File
@@ -1,10 +1,25 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
COPPER_AMOUNT_SYMBOL = "c";
FOCUSTARGET = "Focus Target";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
ITEM_MISSING = "%s missing";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "s";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
TARGETFOCUS = "Target Focus";
+27 -7
View File
@@ -26,6 +26,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID` |
| 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` |
@@ -33,28 +34,30 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [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:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `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.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `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.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [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.GetItemInfoInstant`, `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.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `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` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `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.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [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.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `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.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `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` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [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` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `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` |
### GlueXML calls
@@ -88,6 +91,18 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
### Console commands
Registered on the engine's developer console (the `~` console available
when launching with `-console`), not as Lua functions. See the
[Console section in the Lua reference](docs/API.md#console) for details.
| Command | What it does |
|---------|--------------|
| `ExportInterfaceFiles code` | Extracts Blizzard's UI source (`.lua`/`.xml`/`.toc`/`.xsd`) from the MPQs to `BlizzardInterfaceCode\` |
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
### Events
| Event | Payload |
@@ -151,14 +166,19 @@ See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the im
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
DLL.
Download the prebuilt `ClassicAPI.dll` from the
[latest release](https://github.com/brues-code/ClassicAPI/releases/latest)
(or [build it yourself](#building)). It's loaded with
[VanillaFixes](https://github.com/hannesmann/vanillafixes):
1. Install VanillaFixes if it isn't already.
2. Copy `ClassicAPI.dll` into your game directory.
3. Add `ClassicAPI.dll` to `dlls.txt`.
4. Launch the game with `VanillaFixes.exe`.
The bundled `!!!ClassicAPI` addon ships *inside* the DLL — no separate
addon download or install step needed.
## Bundled addon: !!!ClassicAPI
The Lua-side companion library lives in
+62 -48
View File
@@ -782,24 +782,28 @@ The probe helpers (`_classicapi_ProbeSpellRecord`,
`_classicapi_FindSpellField`) were temporarily exposed to derive the
offset and removed in the same commit that shipped `GetSpellSchool`.
## 38. `GetSpellRadius(spellID)` — easy
## ~~38. `GetSpellRadius(spellID)`~~ — DONE
Returns the AOE radius in yards, or `nil` for non-AOE spells.
`Spell.dbc` has a `SpellRadiusIndex` field that points into
`SpellRadius.dbc` (instance at `0x00C0D7A8` per `docs/DBCs.md`,
already wired up via `VAR_SPELL_RANGE_RECORDS`-style helpers in
`src/spell/Info.cpp`).
Shipped in [src/spell/Radius.cpp](src/spell/Radius.cpp) as
`C_Spell.GetSpellRadius(spellID)` plus the vanilla-positional
`GetSpellRadius(slot, bookType)`. Returns `(baseRadius, modifiedRadius)`,
or `nil` for non-AOE spells. `modifiedRadius` applies the local player's
talent/item radius mods (e.g. Arctic Reach) via the shared `Spell::Mod`
helper — verified in-game: Frost Nova (10230) → `10, 10` without Arctic
Reach, `10, 12` with rank 2 (+20%).
Stride and field layout for `SpellRadius.dbc`: needs derivation
(should be `{id, radius_min, radius, radius_max}` per the public
schema, but verify). Sub-DBC lookup pattern matches what
`LookupSubRecord` already does in `Info.cpp` for icon / cast time /
range.
The radius is **per-effect**, not a single spell-level field: `Spell.dbc`
has `EffectRadiusIndex[3]` at record `+0x160` (verified by decompiling
`FUN_006e6350`, which reads `spellRec[+0x160]`/`[+0x164]` for effects
0/1). Each non-zero index resolves into `SpellRadius.dbc` (records
`0x00C0D7B0`, count `0x00C0D7B4`), record shape
`{ id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }`. We return the
largest base radius across the three effects. Base radius only — the
engine adds `radiusPerLevel * casterLevel`, but that needs a unit context
and modern `GetSpellRadius` reports the base value.
Useful for AOE damage trackers, ground-effect addons, and any aura
library that wants to render an indicator radius. Modern WoW exposes
this via `GetSpellRadius` (deprecated) or returns from
`C_Spell.GetSpellInfo`.
See `OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX`, `VAR_SPELL_RADIUS_RECORDS`,
`OFF_SPELL_RADIUS_VALUE` in [src/Offsets.h](src/Offsets.h).
## ~~39. `GetFactionParentID(factionID)`~~ — DONE
@@ -2309,45 +2313,55 @@ that want this behavior on Octo-style multi-DLL clients can
fall back to debouncing `BAG_UPDATE` themselves via an
`OnUpdate` timer.
## 68. `UnitChannelInfo(unit)` / `UnitCastingInfo(unit)` — medium
## ~~68. `UnitChannelInfo(unit)` / `UnitCastingInfo(unit)`~~ — DONE
Modern signatures return `(name, _, texture, startMs, endMs, _, _,
spellID)` for the unit's currently channeled / cast spell. 1.12
exposes nothing equivalent — the closest is `SPELLCAST_CHANNEL_*`
events, which only fire for the local player and don't carry
spellID payloads.
Shipped in [src/spell/Cast.cpp](src/spell/Cast.cpp): `UnitCastingInfo`,
`CastingInfo`, `UnitChannelInfo`, `ChannelInfo`. Verified in-game —
Blizzard channel `(…, 907894314, 907902314, …, 10187)` (8s), Conjure Food
cast `(…, 907928268, 907931268, …, 28612)` (3s), `GetTime()*1000` falls
between start/end.
The data is sitting in the broadcast descriptor:
**The start/end-time hunt resolved with a firm negative**, confirmed via
the cross-binary technique. 3.3.5's `Script_UnitCastingInfo`
(`0x00611DF0`) / `Script_UnitChannelInfo` (`0x00612090`) read cast times
from **per-unit CGUnit fields** (cast `+0xa6c/+0xa78/+0xa7c`, channel
`+0xa80/+0xa84/+0xa88`) — TBC/WotLK additions that don't exist in the
1.12 CGUnit (the tell: 1.12's *player* cast spellID is a global,
`VAR_CURRENT_CAST_SPELL`, not a unit field). 1.12 stores no cast times
anywhere readable; the cast bar is Lua-driven off `SPELLCAST_START`, and
remote units' casts are transient spell-visual objects
(`FUN_0060d7c0`), not a queryable record. The cast-timer globals at
`0x00cee8d4..e4` are a shared visual-system slot (written for *any*
channeling unit via `FUN_00612a30 → FUN_0060d7c0`), so they're not
player-safe.
- `UNIT_FIELD_CHANNEL_SPELL` at descriptor `+0x228` (already in
`Offsets.h` as `OFF_UNIT_FIELD_CHANNEL_SPELL`). Verified via the
warlock/clicker/fishing diffs in the `IsAssistingRitual` session
— non-zero = currently channeling that spell.
- `UNIT_FIELD_CHANNEL_OBJECT` at descriptor `+0x38/+0x3C`
(`OFF_UNIT_FIELD_CHANNEL_OBJECT`). 64-bit GUID of the channel
target — bobber for fishing, lock for lockpicking, etc. Zero for
channels that don't bind to a GameObject (warlock spells, Ritual
of Summoning).
So timing is **self-tracked for the local player** on `WorldTick`:
- cast: `VAR_CURRENT_CAST_SPELL` 0→nonzero → `startMs = FUN_OS_TICKCOUNT_MS`,
`endMs = start + SpellCastTimes base ms`. Instant casts (cast time 0)
and channels are filtered out of the cast path.
- channel: player's broadcast `UNIT_FIELD_CHANNEL_SPELL` (`+0x228`),
`endMs = start + SpellDuration base ms`.
That gives us spellID immediately, and `name` + `texture` via the
existing `GetSpellInfo(spellID)` chain. Worth shipping a partial
function that omits the `startMs`/`endMs` returns and let callers
do their own duration estimation off `SPELLCAST_CHANNEL_START` —
or hunt for the per-unit cast-time fields the engine uses for the
local castbar.
`FUN_OS_TICKCOUNT_MS` shares `GetTime()`'s epoch (×0.001), so the ms
values feed addon progress math directly. Other units: channel
spellID/name/texture from the broadcast `+0x228` (no times); regular
casts return nil (no vanilla data).
What's missing for the full modern signature: the start/end-time
fields. The local player has `Script_SpellStopCasting`-adjacent
state at fixed VAs (`m_castingSpellId`, `m_castStartMs`,
`m_castEndMs`) — but for *other* units we'd need to either find
broadcast UpdateFields holding the same, or compute end-time on
the client from `Spell.dbc`'s cast time when we see a fresh
UNIT_FIELD_CHANNEL_SPELL value. The 5.4.8/3.3.5 binaries are the
right next stop for this.
`isTradeskill` is real — `SPELL_ATTR_TRADESPELL` (Attributes bit 0x20,
the version-stable bit 3.3.5's Script_UnitCastingInfo reads). Verified:
Rough Blasting Powder (Engineering) → true, Conjure Food → false.
Companion field to investigate while we're in here: `desc 0x1320`
captures the *prior* channeled spellID after the channel ends —
see entry #71 for what that's about.
Cast time is the **effective** time, not base: we call the engine's
cast-time helper `FUN_006e3340` (base + level scaling + cast-time SpellMod
op 10 + the caster's haste/cast-speed multiplier at descriptor +0x22c) —
the same helper the engine's own cast-start uses, so our end time matches
the cast bar. Verified: talented Frostbolt (Improved Frostbolt -0.5s)
reads 2.5s, not the 3.0s base. (Calling the helper beats replicating op 10
via `Spell::Mod`, which would miss the cast-speed multiplier.)
**Follow-up:** `delayTimeMs` pushback tracking (currently 0).
Companion field (`desc 0x1320`, prior channeled spell) — see entry #71.
## 69. `IsGathering()` — easy (local player only)
+735 -36
View File
@@ -33,6 +33,10 @@ build instructions.
- [Combat](#combat)
- [`InCombatLockdown()`](#incombatlockdown)
- [Console](#console)
- [`ExportInterfaceFiles art|code` (console command)](#exportinterfacefiles-artcode-console-command)
- [`ExportDBCFiles` (console command)](#exportdbcfiles-console-command)
- [Container](#container)
- [`C_Container.GetContainerItemID(bagIndex, slotIndex)`](#c_containergetcontaineritemidbagindex-slotindex)
- [`GetItemCooldown(itemInfo)` / `C_Container.GetItemCooldown(itemID)`](#getitemcooldowniteminfo--c_containergetitemcooldownitemid)
@@ -54,6 +58,11 @@ build instructions.
- [`coroutine.wrap(fn)`](#coroutinewrapfn)
- [Async pattern (RunAsync + C_Timer.After)](#async-pattern-runasync--c_timerafter)
- [Creature](#creature)
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
- [`C_CreatureInfo.GetCreatureInfoByID(creatureID)`](#c_creatureinfogetcreatureinfobyidcreatureid)
- [`C_CreatureInfo.RequestLoadCreatureByID(creatureID)`](#c_creatureinforequestloadcreaturebyidcreatureid)
- [CVar](#cvar)
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
@@ -122,6 +131,10 @@ build instructions.
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
- [`C_FriendList.IsWhoQueryPending()`](#c_friendlistiswhoquerypending)
- [GameObject](#gameobject)
- [`C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)`](#c_gameobjectinfogetgameobjectinfobyidgameobjectid)
- [`C_GameObjectInfo.RequestLoadGameObjectByID(gameObjectID)`](#c_gameobjectinforequestloadgameobjectbyidgameobjectid)
- [GameTooltip](#gametooltip)
- [`GameTooltip:SetSpellByID(spellID)`](#gametooltipsetspellbyidspellid)
- [`GameTooltip:SetTalentByID(talentID)`](#gametooltipsettalentbyidtalentid)
@@ -201,6 +214,7 @@ build instructions.
- [`C_Item.UnlockAllItems()`](#c_itemunlockallitems)
- [`C_Item.UnlockItem(itemLocation)`](#c_itemunlockitemitemlocation)
- [`C_Item.UseAtCursor(itemInfo)`](#c_itemuseatcursoriteminfo)
- [`C_Item.UseAtUnit(itemInfo, unit)`](#c_itemuseatunititeminfo-unit)
- [`C_Item.UseItemByName(itemInfo [, unit])`](#c_itemuseitembynameiteminfo--unit)
- [`Get*ItemID` — companions to the engine's `Get*ItemLink` family](#getitemid--companions-to-the-engines-getitemlink-family)
- [`GetAverageItemLevel()`](#getaverageitemlevel)
@@ -249,9 +263,9 @@ build instructions.
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
- [`UnitNameFromGUID(guid)`](#unitnamefromguidguid)
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
- [`C_PlayerCache.RememberPlayer(guid, name, classToken)`](#c_playercacherememberplayerguid-name-classtoken)
- [`C_PlayerCache.SetEnabled(enabled)`](#c_playercachesetenabledenabled)
- [`C_PlayerCache.IsEnabled()`](#c_playercacheisenabled)
@@ -268,6 +282,9 @@ build instructions.
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
- [Screen](#screen)
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
- [Spell](#spell)
- [`C_Spell.DoesSpellExist(spellID)`](#c_spelldoesspellexistspellid)
- [`C_Spell.GetSchoolString(schoolMask)`](#c_spellgetschoolstringschoolmask)
@@ -278,6 +295,9 @@ build instructions.
- [`GetSpellLink(spellID)` / `GetSpellLink(slot, bookType)`](#getspelllinkspellid--getspelllinkslot-booktype)
- [`C_Spell.GetSpellLink(spellID)`](#c_spellgetspelllinkspellid)
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
- [`C_Spell.GetSpellSubtext(spellIdentifier)`](#c_spellgetspellsubtextspellidentifier)
- [`IsPassiveSpell(spellID)` / `IsPassiveSpell(slot, bookType)`](#ispassivespellspellid--ispassivespellslot-booktype)
@@ -297,7 +317,10 @@ build instructions.
- [`GetSpellSchool(spellID)`](#getspellschoolspellid)
- [`CastSpellNoToggle(name | spellID)`](#castspellnotogglename--spellid)
- [`C_Spell.CastAtCursor(spellID)`](#c_spellcastatcursorspellid)
- [`C_Spell.CastAtUnit(spellID, unit)`](#c_spellcastatunitspellid-unit)
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
@@ -346,6 +369,7 @@ build instructions.
- [Unit](#unit)
- [`UnitGUID(unit)`](#unitguidunit)
- [`UnitTokenFromGUID(guid)`](#unittokenfromguidguid)
- [`UnitSubName(unit)`](#unitsubnameunit)
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
- [`UnitClassBase(unit)`](#unitclassbaseunit)
- [`UnitRaceBase(unit)`](#unitracebaseunit)
@@ -824,6 +848,76 @@ if UnitAffectingCombat("player") then
end
```
## Console
### `ExportInterfaceFiles art|code` (console command)
A **developer-console command** (not a Lua function) — type it into the
`~` console, which is available when the client is launched with
`-console`. It extracts Blizzard's stock UI files out of the game's MPQ
archives onto disk, mirroring the same-named command from later clients
(4.3.4). Useful for reading the FrameXML / GlueXML source you're
backporting addons against, or pulling out the default art.
- `ExportInterfaceFiles code` — writes `.lua` / `.xml` / `.toc` /
`.xsd` files to `BlizzardInterfaceCode\…`
- `ExportInterfaceFiles art` — writes `.blp` / `.tga` files to
`BlizzardInterfaceArt\…`
Files are written relative to the client's working directory (next to
`WoW.exe`), preserving their `Interface\…` subtree under the
destination folder — e.g. `Interface\FrameXML\FrameXML.toc` becomes
`BlizzardInterfaceCode\FrameXML\FrameXML.toc`. On completion the command
prints a `wrote N file(s)` line back to the console.
```
> ExportInterfaceFiles code
ExportInterfaceFiles: wrote 1234 code file(s) to BlizzardInterfaceCode\
```
Notes:
- It enumerates the mounted MPQs' `(listfile)`, so it surfaces whatever
the archives actually ship — including Blizzard's own UI addons under
`Interface\AddOns\Blizzard_*` (AuctionUI, TalentUI, TradeSkillUI,
etc.), which are part of the stock UI source. Files present in
multiple archives (base + patches) are written once.
- Your own loose, on-disk addons are *not* exported — they aren't in
any archive's listfile, so they're never enumerated.
- It runs synchronously and briefly freezes the client while it walks
`Interface\` — expected for a one-shot extraction.
### `ExportDBCFiles` (console command)
A ClassicAPI-original companion to `ExportInterfaceFiles` that dumps
every `.dbc` table the client loads to `DBFilesClient\` under the working
directory (next to `WoW.exe`). No subcommand — there's only one thing to
export.
```
> ExportDBCFiles
ExportDBCFiles: wrote 155 .dbc file(s) to DBFilesClient\
```
Unlike the MPQ `(listfile)` alone (which omits ~18 of the tables the
client actually loads — mostly cosmetic animation/sound/lookup DBCs that
Blizzard left out of the index), this unions two sources for a complete
set:
1. the **MPQ `(listfile)`** under `DBFilesClient\` — catches files that
ship in the archives without a loader (e.g. `wowerror_strings.dbc`);
2. a **`.text` scan for the DBC path-getter pattern**
(`mov eax, &"DBFilesClient\X.dbc"; ret`) — the authoritative list of
what this build loads. The master DBC init (`FUN_0053f8b0`) is
straight-line with no iterable name table, so the per-DBC getters
*are* the registry; scanning for them is the same technique that
built [docs/DBCs.md](DBCs.md).
Results are deduped case-insensitively. Handy for a local DBC dump to
inspect schemas/values directly — e.g. verifying a record's column
layout against a known row, the way `C_Item.GetEnchantInfo`'s effect
columns were confirmed — without a standalone MPQ extraction tool.
## Container
### `C_Container.GetContainerItemID(bagIndex, slotIndex)`
@@ -1375,6 +1469,114 @@ fires on the next OnUpdate tick). `RunNextFrame(cb)` in
[`AddOns/!!!ClassicAPI/Util/FunctionUtil.lua`](../AddOns/!!!ClassicAPI/Util/FunctionUtil.lua)
wraps the same primitive for the non-coroutine case.
## Creature
### `C_CreatureInfo.GetCreatureID(guid)`
Extracts the creature template / NPC ID from a unit GUID. Vanilla
1.12 packs the entry ID directly into bits 24-47 of the 64-bit
GUID for the types that carry one; this function does the shift
and mask so addons don't have to.
```lua
C_CreatureInfo.GetCreatureID(UnitGUID("target")) -- 1842 for Hogger
C_CreatureInfo.GetCreatureID(UnitGUID("pet")) -- pet's creature template ID
C_CreatureInfo.GetCreatureID(UnitGUID("player")) -- nil (no entry on players)
```
Accepts creature GUIDs (`0xF130xxxx…`) and pet GUIDs
(`0xF140xxxx…`). Returns `nil` for:
- non-string input or malformed GUIDs
- player GUIDs — the low 32 bits hold a player ID, not a template
- game-object / dynamic-object / corpse / item GUIDs — modern's
`C_CreatureInfo` doesn't surface entry IDs for these even though
the bits are in the same range; addons that need them can shift
the raw GUID themselves (`(guid >> 24) & 0xFFFFFF`)
- entry IDs of 0 — the engine never assigns 0; treated as "no info"
The standard 16-digit (`"0xHHHHHHHHLLLLLLLL"`) and 8-digit
(`"0xLLLLLLLL"`) GUID-string forms are both accepted, the same forms
the `C_PlayerInfo.GUIDIs*` functions take.
The creatureID this returns is exactly what
[`C_CreatureInfo.GetCreatureInfoByID`](#c_creatureinfogetcreatureinfobyidcreatureid)
takes — `GetCreatureInfoByID(GetCreatureID(UnitGUID("target")))` gives
the target's cached name/type/family/rank.
### `C_CreatureInfo.GetCreatureInfoByID(creatureID)`
Reads the client-side **creature cache** (`creaturecache.wdb`, fed by
`SMSG_CREATURE_QUERY_RESPONSE`) for an NPC/creature by ID and returns a
table — or `nil` if it isn't cached:
```lua
local info = C_CreatureInfo.GetCreatureInfoByID(61332)
-- {
-- creatureID = 61332,
-- name = "Misthoof Stag",
-- subName = "", -- title/subtitle ("" if none)
-- type = 1, -- CreatureType: 1=Beast, 7=Humanoid, …
-- family = 0, -- CreatureFamily
-- rank = 0, -- 0=normal, 1=elite, 2=rareelite, 3=worldboss, 4=rare
-- displayID = 10957, -- model display ID
-- }
```
This is a synchronous **peek** — it returns data only for creatures
already cached (seen this session, or loaded from `creaturecache.wdb`
at login), and `nil` otherwise. Unlike `UnitCreatureType`/
`UnitClassification`, which only work on a *live unit token*, this
answers for any creatureID you have cached — even one not currently in
the world.
Reads through the engine's generic cache `_GetRecord` (with no callback,
so it's a pure lookup — no network query) and pulls the fields straight
off the cached data block. Field offsets verified against the binary
(rank read by the engine's classification helper at `[block+0x20]`) and
real `creaturecache.wdb` rows (Misthoof Stag→type 1, Nordrassil
Nymph→type 7 rank 1, Greathorn Hunter→family 26).
For a creature that isn't cached yet, call
[`RequestLoadCreatureByID`](#c_creatureinforequestloadcreaturebyidcreatureid)
first and read it once `CREATURE_DATA_LOAD_RESULT` fires.
### `C_CreatureInfo.RequestLoadCreatureByID(creatureID)`
Asynchronously fetches a creature that isn't in the cache yet — issues
`SMSG_CREATURE_QUERY` and fires **`CREATURE_DATA_LOAD_RESULT(creatureID,
success)`** when the response lands, at which point
[`GetCreatureInfoByID`](#c_creatureinfogetcreatureinfobyidcreatureid)
returns its data. The same shape as
[`C_Item.RequestLoadItemData`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("CREATURE_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
-- 1.12 passes event args as globals: arg1 = creatureID, arg2 = success
if arg2 then
local info = C_CreatureInfo.GetCreatureInfoByID(arg1)
-- info.name, info.type, info.rank, …
end
end)
C_CreatureInfo.RequestLoadCreatureByID(10184) -- Onyxia → fires the event,
-- then GetCreatureInfoByID works
```
Returns `true` if the request was accepted (`false` only on bad input
or a full pending set). If the creature is **already** cached, the
event fires synchronously with `success = true`. On a query that never
resolves it fires once with `success = false` after a timeout.
Implementation: a shared `Cache::QueryLoad` dispatcher hooks the engine's
generic cache response parser (`FUN_00556E20`) **once** and routes by
cache instance — the same hook serves the gameobject cache too. The
creature/gameobject caches use this generic parser; the item and quest
caches have their own, so there's no hook collision. Verified in-game: `RequestLoadCreatureByID(10184)` on
an uncached Onyxia fired the event and populated the cache (type 2
Dragonkin, rank 3 world boss).
## CVar
### `C_CVar.GetCVarBool(cvar)`
@@ -2802,6 +3004,48 @@ contribute to the same pending window. This is acceptable for the
addons that need per-call tracking should manage their own ticket
state on top.
## GameObject
The gameobject analog of [`C_CreatureInfo`](#creature) — reads the
client-side gameobject cache (`gameobjectcache.wdb`, fed by
`SMSG_GAMEOBJECT_QUERY_RESPONSE`) by GO entry ID.
### `C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)`
Synchronous **peek** — returns a table for a cached gameobject, `nil`
otherwise:
```lua
local info = C_GameObjectInfo.GetGameObjectInfoByID(47297)
-- { gameObjectID = 47297, name = "Mesa Elevator", type = 11, displayID = 360 }
```
`type` is the `GameObjectType` (0=door, 1=button, 3=chest, 5=generic,
6=trap, 10=goober, …). Field offsets verified against the binary
(name `[block+0x08]`) and real `gameobjectcache.wdb` rows (Mesa
Elevator 47297→type 11; Windrunner 176250→type 15 displayID 7087).
### `C_GameObjectInfo.RequestLoadGameObjectByID(gameObjectID)`
Asynchronously fetches an uncached gameobject — issues
`SMSG_GAMEOBJECT_QUERY` and fires **`GAMEOBJECT_DATA_LOAD_RESULT(
gameObjectID, success)`** when it lands, after which
`GetGameObjectInfoByID` returns its data. Returns `true` if accepted;
fires synchronously on a cache hit. Same shape as
[`C_CreatureInfo.RequestLoadCreatureByID`](#c_creatureinforequestloadcreaturebyidcreatureid),
and rides the same shared `Cache::QueryLoad` dispatcher (the gameobject
cache is a sibling class of the creature cache with its own
`_GetRecord`/parser, hooked separately).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("GAMEOBJECT_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function() -- arg1 = gameObjectID, arg2 = success
if arg2 then local info = C_GameObjectInfo.GetGameObjectInfoByID(arg1) end
end)
C_GameObjectInfo.RequestLoadGameObjectByID(7000032)
```
## GameTooltip
### `GameTooltip:SetItemByID(itemID)`
@@ -4752,6 +4996,32 @@ Cancels placement automatically when the cursor isn't on terrain —
the item-use packet is never sent, so an off-screen click doesn't
waste the grenade.
### `C_Item.UseAtUnit(itemInfo, unit)`
Unit-position analog of
[`C_Item.UseAtCursor`](#c_itemuseatcursoriteminfo): uses `itemInfo` at
`unit`'s feet rather than the cursor. ClassicAPI's `[@unit]` for
ground-target on-use items. `itemInfo` accepts the same forms as
`UseAtCursor` (itemID, bare `"item:N"`, full chat link, localized
name); `unit` is any unit token (`"player"`, `"target"`,
`"mouseover"`, `"party1"`, …).
```lua
C_Item.UseAtUnit(4068, "target") -- Iron Grenade at the target's feet
C_Item.UseAtUnit("Iron Grenade", "player")
```
Same chain as `UseAtCursor` (`Item::Location::FindByArgInBags` +
`FUN_ITEM_USE`) but committing the placement at the unit's world
position via
[`Spell::AtCursor::CommitAtCoords`](#c_spellcastatunitspellid-unit)
instead of the cursor raycast. Returns `true` when the placement
landed at the unit; `false` for non-ground-target items (the item
still fires with any implicit target), unparseable input,
item-not-in-bags, or an unresolvable unit. The unit is resolved
before the item fires, so an absent unit fails without consuming the
item.
### `C_Item.UseItemByName(itemInfo [, unit])`
Finds the first item in the player's bags matching `itemInfo` and
@@ -6003,6 +6273,35 @@ instance lives at `0x00C0E228`; entry layout (name, realm, race,
sex, class) was reverse-engineered from the
`SMSG_NAME_QUERY_RESPONSE` write path at `0x0055F310`.
### `UnitNameFromGUID(guid)`
Returns `name, realm` for the player identified by `guid`, or `nil`
if no player with that GUID has been encountered yet. Same lookup
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
NameCache, persistent on-disk fallback when enabled) — just narrower
return shape for callers that only need the name.
```lua
local name, realm = UnitNameFromGUID(UnitGUID("target"))
if name then
ChatFrame1:AddMessage("Target: " .. name)
end
-- Resolving a chat link
local name, realm = UnitNameFromGUID("0x0000000000000777")
```
`realm` is always `""` in vanilla — the engine doesn't populate
per-player realm names and 1.12 has no cross-realm interaction.
We push the empty string rather than `nil` to match the convention
of the other player-info accessors; addons can gate on `realm == ""`.
Doesn't trigger a network query on miss. To populate an unknown
GUID, either let the engine receive a `SMSG_NAME_QUERY_RESPONSE`
through normal chat/target interaction, or call
[`C_PlayerCache.RememberPlayer`](#c_playercacherememberplayerguid-name-classtoken)
explicitly.
### `C_PlayerCache.GetPlayerInfoByName(name)`
Returns
@@ -6139,33 +6438,6 @@ Accepts either the 16-digit `"0xHHHHHHHHLLLLLLLL"` form or the
Malformed input returns `false` rather than raising — matching
modern's tolerance for stale GUIDs from addon-side caches.
### `C_CreatureInfo.GetCreatureID(guid)`
Extracts the creature template / NPC ID from a unit GUID. Vanilla
1.12 packs the entry ID directly into bits 24-47 of the 64-bit
GUID for the types that carry one; this function does the shift
and mask so addons don't have to.
```lua
C_CreatureInfo.GetCreatureID(UnitGUID("target")) -- 1842 for Hogger
C_CreatureInfo.GetCreatureID(UnitGUID("pet")) -- pet's creature template ID
C_CreatureInfo.GetCreatureID(UnitGUID("player")) -- nil (no entry on players)
```
Accepts creature GUIDs (`0xF130xxxx…`) and pet GUIDs
(`0xF140xxxx…`). Returns `nil` for:
- non-string input or malformed GUIDs
- player GUIDs — the low 32 bits hold a player ID, not a template
- game-object / dynamic-object / corpse / item GUIDs — modern's
`C_CreatureInfo` doesn't surface entry IDs for these even though
the bits are in the same range; addons that need them can shift
the raw GUID themselves (`(guid >> 24) & 0xFFFFFF`)
- entry IDs of 0 — the engine never assigns 0; treated as "no info"
The standard 16-digit (`"0xHHHHHHHHLLLLLLLL"`) and 8-digit
(`"0xLLLLLLLL"`) GUID-string forms are both accepted, same as
`C_PlayerInfo.GUIDIs*` above.
### `C_PlayerCache.SetEnabled(enabled)`
Opts into (or out of) the persistent name cache. `enabled` is a
@@ -6545,6 +6817,40 @@ A separate function keeps the existing call wire-compatible.
> first, skip zero slots, then the item array. 1-based `objectiveIndex`
> counts only non-empty slots.
## Screen
### `GetPhysicalScreenSize()`
Returns `(widthPixels, heightPixels)` — the display's physical
resolution in pixels. Modern WoW added this as a native in 7.0
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
conversion on it (`768.0 / physicalHeight`), which is what lets addons
snap frames to whole pixels for crisp borders at fractional UI scales.
```lua
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
```
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
pixels. The engine only ever holds the current mode as the
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
than caching dimensions anywhere. So we read the `gxResolution` CVar
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
`+0x20`) and parse it — the same source of truth the engine uses.
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
the CVar is missing or unparseable. Caveat: in windowed mode
`gxResolution` reflects the configured render resolution, which may
differ from the OS window's client size — vanilla exposes no separate
window-pixel query.
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
consumes this function.
## Spell
> **All `C_Spell.*` functions in this section accept any spell
@@ -6802,6 +7108,134 @@ local desc = C_Spell.GetSpellDescription(133) -- Fireball Rank 1
> "currently displayed" tooltip text with caster scaling, use
> `GameTooltip:SetSpellByID` and read line strings from there.
### `C_Spell.GetSpellMechanicByID(spellID)`
Returns the spell's mechanic as `(mechanicID, name)` — the standard WoW
`SpellMechanic` ID and its English name. Works for **any** `spellID`, not
just spells the player has learned.
Returns:
- nothing (`nil`) if the spell ID is invalid / out of range
- `(0, nil)` for a known spell that carries no mechanic
- `(mechanicID, name)` otherwise
The `name` is always the **enUS** string (locale-independent), because the
mechanic name is a stable token consumers string-match against
(`"rooted"`, `"stunned"`, `"polymorphed"`, …) — returning a localized
value would break that matching across clients. It is `nil` only when the
mechanic has no `SpellMechanic.dbc` record or no English name; the numeric
ID is still returned in that case.
```lua
C_Spell.GetSpellMechanicByID(118) -- Polymorph → 17, "polymorphed"
C_Spell.GetSpellMechanicByID(339) -- Entangling Roots → 7, "rooted"
C_Spell.GetSpellMechanicByID(133) -- Fireball → 0, nil
```
This reads `Spell.dbc`'s `Mechanic` field and resolves the name from
`SpellMechanic.dbc` directly — both DBCs are resident from boot, so the
call is synchronous with no caching or network round-trip (unlike item
data). It replaces hand-maintained `spellID → mechanic` lookup tables that
addons otherwise keep because vanilla exposes no Lua reader for the field
— e.g. crowd-control macro conditionals (`[cc:stun]`, `[cc:fear]`) that
need to know which mechanic a debuff applies.
The complete `SpellMechanic.dbc` set on 1.12 — all 27 rows (the `name`
column is exactly what this function returns):
| ID | name | ID | name | ID | name |
|----|------|----|------|----|------|
| 1 | `charmed` | 10 | `asleep` | 19 | `shielded` |
| 2 | `disoriented` | 11 | `ensnared` | 20 | `shackled` |
| 3 | `disarmed` | 12 | `stunned` | 21 | `mounted` |
| 4 | `distracted` | 13 | `frozen` | 22 | `seduced` |
| 5 | `fleeing` | 14 | `incapacitated` | 23 | `turned` |
| 6 | `clumsy` | 15 | `bleeding` | 24 | `horrified` |
| 7 | `rooted` | 16 | `healing` | 25 | `invulnerable` |
| 8 | `pacified` | 17 | `polymorphed` | 26 | `interrupted` |
| 9 | `silenced` | 18 | `banished` | 27 | `dazed` |
> **No mechanic `30`.** The table tops out at `27`. Sap and Gouge report
> `14` (incapacitated) in 1.12 — the `30` ("sapped") value used by some
> addon tables is a later-expansion addition and has no row here.
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
or `nil` for a non-AOE spell (no effect carries a radius). Works for any
spell, not just ones the player has learned.
- **`baseRadius`** — the raw `SpellRadius.dbc` value; caster-independent,
what an AOE tracker watching other units wants.
- **`modifiedRadius`** — `baseRadius` with the **local player's** talent /
item modifiers applied (e.g. a Mage's Arctic Reach). Equal to
`baseRadius` when the player has no matching modifier.
```lua
C_Spell.GetSpellRadius(122) -- Frost Nova → 10, 10 (no Arctic Reach)
C_Spell.GetSpellRadius(122) -- Frost Nova → 10, 12 (with Arctic Reach r2)
C_Spell.GetSpellRadius(133) -- Fireball → nil (single-target)
GetSpellRadius(10, "spell") -- 10th player-book slot, by spellbook position
```
Two signatures matching the dual-signature shape used elsewhere in this
backport (`GetSpellInfo`, `SpellHasRange`, …): the namespaced form takes
a modern spell *identifier* (numeric ID or name); the bare global takes
the vanilla `(slot, bookType)` spellbook position, where `bookType`
follows the `GetSpellName` convention — `"pet"` → pet book, `"spell"`
(or any non-pet / omitted value) → player book.
`baseRadius` reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]`
(`+0x160`) and resolves each into `SpellRadius.dbc`; since a spell's
effects can each carry their own radius, the **largest** base radius
across the three effects is returned.
> **No caster-level scaling.** The engine's internal radius helper also
> adds `radiusPerLevel * casterLevel`, but that needs a unit context;
> like modern `GetSpellRadius` this reports the base value. Talent/item
> modifiers (the `modifiedRadius` return) **are** applied, via the
> engine's SpellMod system — but only the **local player's**, since the
> client tracks spell mods for the player alone (there's no way to know
> another caster's talents). All DBCs / mod tables are resident from
> boot, so the call is synchronous with no caching.
### `C_Spell.GetSpellPowerCost(spellIdentifier)`
Returns an array of `SpellPowerCostInfo` tables, or `nil` if the spell
isn't found or has no resource cost. Vanilla spells have exactly one
power cost, so the array holds at most one entry.
```lua
C_Spell.GetSpellPowerCost(10187) -- Blizzard, Mage with 3/3 Frost Channeling
-- { [1] = { type = 0, name = "MANA", cost = 1190, minCost = 1190,
-- costPercent = 0, costPerSec = 0, requiredAuraID = 0,
-- hasRequiredAura = false } }
-- base cost 1400, 15% reduction applied -> 1190
```
| Field | Meaning |
|-------|---------|
| `type` | `Enum.PowerType` — `0` mana, `1` rage, `2` focus, `3` energy, `4` happiness (the spell's `PowerType`) |
| `name` | power token (`"MANA"`, `"RAGE"`, …) |
| `cost` | **effective** cost for the local player |
| `minCost` | `== cost` (vanilla has no optional-cost component) |
| `costPercent` | the spell's `%`-of-base-resource cost, or `0` for a flat cost |
| `costPerSec` | `0` (vanilla doesn't expose a per-second channel cost in these terms) |
| `requiredAuraID` | `0` (no form/aura-conditional costs in 1.12) |
| `hasRequiredAura` | `false` |
`cost` is the value the engine actually charges — base + level scaling +
`ManaCostPercent`-of-resource + descriptor power-cost mods + the cost
SpellMod (talents like Frost Channeling). It comes from the engine's own
cost helper (`FUN_006e31b0`), so it stays in lockstep with what casting
the spell deducts.
> **Effective vs. base.** This returns the player-modified cost.
> [`C_Spell.GetSpellInfo`](#c_spellgetspellinfospellid)'s `cost` field is
> the **base** `ManaCost` (intrinsic spell data, caster-independent) —
> same base/modified split as
> [`GetSpellRadius`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype).
### `C_Spell.GetSpellReagents(spellID)`
Returns the spell's reagent list as an array of
@@ -7161,6 +7595,70 @@ addons reading positions 4..8 by index still work.
Equivalent to the extension of `GetWeaponEnchantInfo` introduced
in 3.x.
### `C_Item.GetEnchantInfo(enchantID)`
Resolves an item-enchantment ID — the `enchantID` returned by
[`C_Item.GetWeaponEnchantInfo`](#c_itemgetweaponenchantinfo) for a
weapon's temporary enchant, and the same IDs item permanent enchants
use — into a table:
```lua
local info = C_Item.GetEnchantInfo(enchantID)
-- info.enchantID = <id>
-- info.name = "Crusader" -- localized display name
-- info.effects = { {type=1, amount=0, arg=20007} }
-- info.spellID = 20007 -- spell-type enchants only
```
```lua
-- A proc/equip enchant → chain its spellID into C_Spell:
local info = C_Item.GetEnchantInfo(1900) -- "Crusader"
if info.spellID then
print(C_Spell.GetSpellDescription(info.spellID)) -- the proc's text
end
```
`effects` is an array of the record's non-empty effect slots, each
`{ type, amount, arg }` where `type` is the standard
`ITEM_ENCHANTMENT_TYPE`:
| type | meaning | carries |
|------|---------|---------|
| 1 | combat-proc spell | `arg` = spellID |
| 2 | weapon damage | `amount` = +damage |
| 3 | equip spell / aura | `arg` = spellID |
| 4 | resistance / armor | `amount` = +value |
| 5 | stat | `arg` = stat index, `amount` = value |
| 6 | totem | — |
| 7 | use spell | `arg` = spellID |
For spell types (1/3/7) `arg` is a spellID feedable into
[`C_Spell.GetSpellInfo`](#getspellinfospellid--getspellinfoslot-booktype) /
`GetSpellDescription`; the first such id is also surfaced at top level
as `spellID` for convenience (absent for non-spell enchants like
sharpening stones).
Returns `nil` for a non-numeric / non-positive id, an out-of-range
id, or a record with no name.
Reads `SpellItemEnchantment.dbc` (records `0x00C0D7D8`, count
`0x00C0D7DC`) — the 24-column table every enchant ID indexes:
`Type[3]@+0x04`, `Amount[3]@+0x10`, `EffectArg[3]@+0x28`,
`Name[8]@+0x34` (locale-indexed). The layout was verified by parsing
the on-disk DBC against known records (Crusader 1900 → type 1, arg
20007; Sharpened +3 → type 2, amount 3). Fully resident from boot, so
it answers for any enchant ID with no caching or round-trip.
Lives in `C_Item` (not `C_Spell`) because the id originates from
`C_Item.GetWeaponEnchantInfo` and the concept is an item enchantment —
`SpellItemEnchantment` is just the DBC's internal name (enchants are
*implemented* via spell effects).
> **Not derivable: the source item.** The enchant record holds no
> back-reference to the item that applied it, and vanilla has no
> client-side reverse index (enchantID → item). Finding it would need
> an external scraped DB (pfQuest/Questie-style).
### `IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`
Classify a spell as offensive (`IsHarmfulSpell`) or non-offensive
@@ -7356,6 +7854,36 @@ The companion item version is
[`C_Item.UseAtCursor`](#c_itemuseatcursoriteminfo) — same chain via
the item-use path for grenades / on-use ground-target items.
### `C_Spell.CastAtUnit(spellID, unit)`
Casts a ground-target spell at `unit`'s feet, bypassing the AoE
reticle click. ClassicAPI's analog of modern's
`/cast [@unit] Flamestrike` — `CastAtUnit(spellID, "player")` drops it
at the player's position, `"target"` at the target's, and so on for
any unit token (`"mouseover"`, `"party1"`, `"raid7"`, …). Returns
`true` when the placement landed at the unit; `false` for
non-ground-target spells (cast still fires normally), unknown
spellIDs, or a unit with no resolvable position.
```lua
C_Spell.CastAtUnit(2120, "target") -- Flamestrike at the target's feet
C_Spell.CastAtUnit(10, "player") -- Blizzard centered on yourself
```
Same two-stage shape as
[`C_Spell.CastAtCursor`](#c_spellcastatcursorspellid), but instead of
the cursor raycast, step 2 reads the unit's world position from its
`GetPosition` virtual (the same one `CheckInteractDistance` /
`UnitInRange` use) and commits placement there via
`FUN_COMMIT_PLACEMENT_COORDS`. The unit is resolved *before* the cast
is dispatched, so an absent/invalid unit fails cleanly without
leaving the engine armed in placement mode. A genuinely unrecognized
unit-token string raises the engine's standard "Unknown unit" error,
matching `UnitHealth("garbage")`.
The companion item version is
[`C_Item.UseAtUnit`](#c_itemuseatunititeminfo-unit).
### `C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`
Cancel a buff on the player by spellID or by spell name. Modern WoW's
@@ -7391,6 +7919,92 @@ pass the plain name. Multi-rank buffs match on first-found; if you
have multiple ranks of the same buff active (unusual but possible with
e.g. paladin blessings before talent merge), the first slot wins.
### `C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`
Returns the unit's in-progress **regular cast** (not a channel), or
`nil` if it isn't casting. `C_Spell.CastingInfo()` is
`C_Spell.UnitCastingInfo("player")` without the unit-token lookup.
> **Under `C_Spell`, not the global `UnitCastingInfo`.** Modern WoW
> exposes these as globals, but a global here would clobber addons that
> ship their own vanilla cast-tracking via the `_G.UnitCastingInfo or
> <fallback>` idiom — e.g. ShaguTweaks' `libcast` scrapes the combat
> log for **remote/enemy** casts the 1.12 engine never exposes. If we
> occupied the global, those addons would adopt our player-only version
> and lose their (superior, for that case) fallback. So we cede the
> global names and register under `C_Spell` instead. Same for
> `UnitChannelInfo`/`ChannelInfo`.
```
name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
castID, notInterruptible, castingSpellID, castBarID, delayTimeMs
= C_Spell.UnitCastingInfo(unit)
```
```lua
-- mid-cast:
C_Spell.UnitCastingInfo("player")
-- "Conjure Food", "Conjure Food", "Interface\\Icons\\INV_Misc_Food_...",
-- 907928268, 907931268, false, nil, false, 28612, nil, 0
```
`startTimeMs`/`endTimeMs` share `GetTime()`'s epoch (`GetTime()*1000`),
so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> **Works for any unit.** Vanilla 1.12 stores no cast times on the
> CGUnit (the cast bar is Lua-driven off `SPELLCAST_START`), so we
> source them two ways: the **local player** is self-tracked on a
> per-frame tick (`VAR_CURRENT_CAST_SPELL`, stamping `startTimeMs = now`,
> `endTimeMs = now + effective cast time`); **other units** come from a
> co-hook on `SMSG_SPELL_START` (`Spell::Cast`), the one packet carrying
> another unit's cast with a server-authoritative cast time, cached per
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
> cast bar). Best-effort for remote units: only casts that began while the
> unit was in range are seen, and — since 1.12 has no per-unit interrupt
> packet to hook — an *interrupted* remote cast lingers until its computed
> `endTimeMs` rather than clearing instantly.
>
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
> profession recipe casts (smelting, cooking/enchanting recipes, …)
> return `true` while regular spells return `false`. `endTimeMs` reflects
> the **effective** cast time (the engine's own cast-time helper: base +
> level scaling + cast-time talents like Improved Frostbolt + the
> haste/cast-speed multiplier), so it matches the in-game cast bar — e.g.
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. Fields
> vanilla can't fill are structurally-correct placeholders:
> `castID`/`castBarID` = `nil`, `notInterruptible` = `false`,
> `delayTimeMs` = `0`.
### `C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`
Returns the unit's in-progress **channel**, or `nil` if it isn't
channeling. `C_Spell.ChannelInfo()` is `C_Spell.UnitChannelInfo("player")`
without the token lookup. (Under `C_Spell` rather than the global name
for the same reason as
[`C_Spell.UnitCastingInfo`](#c_spellunitcastinginfounit--c_spellcastinginfo).)
```
name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
notInterruptible, spellID = C_Spell.UnitChannelInfo(unit)
```
```lua
C_Spell.ChannelInfo()
-- "Blizzard", "Blizzard", "Interface\\Icons\\Spell_Frost_IceStorm",
-- 907894314, 907902314, false, false, 10187
```
The channeled spellID **is** broadcast for every unit
(`UNIT_FIELD_CHANNEL_SPELL`), so `C_Spell.UnitChannelInfo("target")` works
on other units. Timing now comes from the same `SMSG_SPELL_START` co-hook
as casts: when we observed the channel begin, the remote unit returns full
**start/end times** (validated against the live `UNIT_FIELD_CHANNEL_SPELL`
so a stale cache entry never applies to a different/ended channel);
otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.
## SpellBook
Functions keyed on the player or pet **spellbook** (slot indices,
@@ -7636,7 +8250,7 @@ This is distinct from spell channeling: the function fires for
*participants* who clicked the portal, not the warlock who cast
Ritual of Summoning. Vanilla has no Lua surface for this state —
the engine's `SPELLCAST_CHANNEL_*` events don't fire and
`CastingInfo()` returns nothing — so addons that want to react to
`C_Spell.CastingInfo()` returns nothing — so addons that want to react to
the player being committed to a ritual (e.g. suppress autorun
toggles, hide nameplate clicks, warn before movement) have no other
way to detect it.
@@ -8251,6 +8865,33 @@ end
> ex-targets, ex-mouseover units, distant players seen in the chat
> log, etc. The engine simply doesn't address those by token.
### `UnitSubName(unit)`
Returns the NPC's subtitle / title string (the small italic text
under their name in the tooltip): `"Innkeeper"`, `"Stable Master"`,
`"<Master Engineer>"`, etc. Returns `nil` for players, unsynced
NPCs, and NPCs with no subtitle.
```lua
/dump UnitSubName("target")
-- "Innkeeper" (Stormwind innkeep)
-- "<Auction House>" (auctioneer)
-- nil (a player, or a wolf in the woods)
```
Modern WoW returns the subtitle as `UnitName`'s second return;
vanilla 1.12's `UnitName` returns only one value, leaving no
direct route to the subtitle. Addons that wanted it had to scrape
`GameTooltip` text, which is fragile and required the unit to be
hovered. `UnitSubName` reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip round-trip.
Coverage caveat: the creature cache row is populated when an NPC
becomes visible to the client AND the server has sent its
`CMSG_CREATURE_QUERY_RESPONSE`. The first time a fresh NPC appears
the row may be briefly NULL — usually only one or two frames.
Subsequent calls succeed once the response lands.
### `GetUnitSpeed(unit)`
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
@@ -8635,21 +9276,79 @@ selects.
| `dispelName` | string | `"Magic"` / `"Curse"` / `"Disease"` / `"Poison"` (from `SpellDispelType.dbc`), or `""` if non-dispellable |
| `isHelpful` | boolean | true for slot < 32 |
| `isHarmful` | boolean | true for slot >= 32 |
| `duration` | number | base applied duration in seconds, looked up via `Spell.dbc → SpellDuration.dbc` with level scaling. Talent / glyph duration extensions (Improved PW:F, etc.) aren't reflected here — those are baked into `expirationTime` on the caster's side. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs) |
| `expirationTime` | number | populated for `unit == "player"` (read from the engine's player-buff table at `0x00BC6040`; same data `GetPlayerBuffTimeLeft` returns). Always `0` for all other units — vanilla server only broadcasts cast/duration for the local player's own auras. `expirationTime - GetTime()` gives the true remaining time (including any talent extensions) |
| `duration` | number | applied duration in seconds. When the aura's cast was observed (in the `Aura::Source` cache), this is the caster-modified duration — talent/glyph extensions like Improved Shadow Word: Pain included — so it stays consistent with `expirationTime` (`remaining ≤ duration`). On a cache miss it falls back to the base `Spell.dbc → SpellDuration.dbc` value with level scaling. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs) |
| `expirationTime` | number | for `unit == "player"`, read from the engine's player-buff table at `0x00BC6040` (same data `GetPlayerBuffTimeLeft` returns). For any other unit, taken from the `Aura::Source` cache (cast time + duration captured from `SMSG_SPELL_GO`; see below). `0` when neither source has it. `expirationTime - GetTime()` gives the true remaining time |
| `sourceUnit` | string | unit token of the caster (`"player"`, `"raid7"`, `"nameplate1"`, …), resolved from the `Aura::Source` cache. `nil` if the cast wasn't observed or the caster maps to no current token |
| `sourceGUID` | string | caster's `"0x…"` GUID string from the same cache. **ClassicAPI extension — not a retail `AuraData` field.** Set whenever a caster is known, including when `sourceUnit` is `nil` (caster left token range). Stable for the session, unlike the volatile nameplate token; doubles as a unit token under SuperWoW. `nil` on a cache miss |
| `charges` / `maxCharges` | number | always `0` — vanilla has stacks, not charges |
| `timeMod` | number | always `1` — vanilla has no haste-affected auras |
| `isStealable`, `isBossAura`, `isFromPlayerOrPlayerPet`, `isNameplateOnly`, `nameplateShowAll`, `nameplateShowPersonal`, `canApplyAura`, `shouldConsolidate`, `isRaid` | boolean | always `false` — modern UI concepts vanilla doesn't have |
| `sourceUnit`, `auraInstanceID`, `points` | (absent) | omitted from the table — Lua read yields nil, matching modern semantics for "field doesn't apply" |
| `auraInstanceID`, `points` | (absent) | omitted from the table — Lua read yields nil, matching modern semantics for "field doesn't apply" |
### Filter parsing
The optional `filter` string is a pipe-separated set of upper-case
tokens, matching modern syntax (`"HELPFUL"`, `"HARMFUL"`,
`"HELPFUL|PLAYER"`, etc.). Only `HELPFUL` (default) and `HARMFUL`
are honored on vanilla; other tokens (`PLAYER` / `RAID` /
`CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) are accepted but no-op —
they'd need source-GUID tracking or systems vanilla doesn't have.
`"HELPFUL|PLAYER"`, etc.). Honored on vanilla:
- **`HELPFUL`** (default) / **`HARMFUL`** — pick the buff or debuff range.
In `GetUnitAuras`, supplying neither returns both ranges.
- **`PLAYER`** — restrict to auras the local player cast, via the
`Aura::Source` caster cache (`sourceGUID == ` player GUID). Combines with
the range tokens (`"HARMFUL|PLAYER"` = your debuffs only). Because the
caster is best-effort, an aura whose cast we didn't observe is treated as
not-player-cast and excluded — so `PLAYER` can under-report auras that
predate login.
Other tokens (`RAID` / `CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) are
accepted but no-op — they need engine systems (raid-dispel relevance,
nameplate visibility flags) vanilla doesn't have.
### Caster & timing (`Aura::Source`)
`sourceUnit`, `sourceGUID`, and non-player `expirationTime` come from a
client-side cache that vanilla itself can't provide: the unit aura array
stores only spell IDs — never the caster, and no cast/expiration timing for
anyone but the local player. `Aura::Source` fills the gap by co-hooking
three engine functions and caching `(targetGuid, spellId) → { casterGuid,
expirationMs, durationMs }`; `Push` keys into it by the unit's GUID + the
aura's spell ID.
- **`SpellGo`** (`FUN_SPELL_GO`, `0x006E7A70`) — the cast packet, the only
place the client sees an aura's **caster** + a server-authoritative,
caster-modified duration. Fills `sourceUnit`/`sourceGUID` + timing for
directly-cast auras.
- **`OnAuraAdded`** (`0x006123F0`) — fires when any aura first occupies a
slot, including proc/triggered auras that emit no `SMSG_SPELL_GO` (Shadow
Weaving etc.). Stamps `expirationMs = now + base duration` (no caster).
- **`OnAuraStacksChanged`** (`0x00612450`) — fires when an existing aura's
stack count changes; re-stamps expiration so a climbing stacking debuff
(e.g. Shadow Weaving 1→5) refreshes.
Application hooks never overwrite an entry `SpellGo` already owns, so a
directly-cast aura keeps its talented timing + caster regardless of hook
order.
Implications:
- **Best-effort.** Only auras observed *after* login carry data; auras
already up when you logged in leave the defaults (`sourceUnit`/`sourceGUID`
nil, non-player `expirationTime` 0).
- **Max-stack refresh is a blind spot.** When a stacking debuff is refreshed
at *max* stacks (e.g. Shadow Weaving 5→5), no client-visible aura field
changes (spellID, stacks, flags, level all identical), so the server sends
no UpdateField and the engine fires no callback — there is no packet to
hook (verified: the stack-change dispatcher `FUN_00604ea0` calls
`OnAuraStacksChanged` unconditionally, so its silence proves nothing
arrived). The countdown therefore runs to 0 and the entry evicts; once
evicted, `expirationTime` falls back to 0 (unknown) while the aura is still
shown. Climbing stacks and single-stack refreshes (re-cast → fresh
`SpellGo`) are unaffected.
- **No nampower dependency.** We parse the engine functions ourselves;
nampower (which hooks the same three) need not be loaded — when it is, we
co-hook the sites.
- Entries are evicted once their timed aura elapses, so the cache stays
bounded; infinite-duration auras persist until overwritten under load.
### `C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`
+19
View File
@@ -236,6 +236,25 @@ void PushLocalizedFormatInt(void *L, const char *globalName,
}
} // namespace Lua
namespace Console {
namespace {
using RegisterCommand_t = int(__fastcall *)(const char *name, void *handler,
int category, const char *description);
using Write_t = void(__fastcall *)(const char *line, int colorFlag);
} // namespace
void RegisterCommand(const char *name, CommandHandler handler, int category,
const char *description) {
auto fn = reinterpret_cast<RegisterCommand_t>(Offsets::FUN_CONSOLE_COMMAND_REGISTER);
fn(name, reinterpret_cast<void *>(handler), category, description);
}
void Write(const char *line) {
auto fn = reinterpret_cast<Write_t>(Offsets::FUN_CONSOLE_WRITE);
fn(line, 0);
}
} // namespace Console
namespace {
ModuleAutoRegister *g_moduleHead = nullptr;
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
+44
View File
@@ -242,6 +242,50 @@ void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
} // namespace Lua
// Developer-console command system — the `~` console available when the
// client is launched with `-console`. Vanilla maintains a
// `TSExplicitList<CONSOLECOMMAND>`; these wrap the engine's registrar
// and output paths. See the "Console commands & Interface file export"
// section in CLAUDE.md for the underlying offsets and ABI.
namespace Console {
// Console-command handler ABI: `args` is the command-line text after
// the command name (empty string when invoked bare). The engine
// ignores the return value; return 1 by convention.
using CommandHandler = int(__fastcall *)(void *unused, const char *args);
// Help-grouping categories — the engine's `help` enumerates these nine
// (in this order). Cosmetic: the value only affects which group `help`
// lists the command under; the engine doesn't validate it. Values are
// sequential table indices (graphics=1 confirmed via FUN_0066F6C0, the
// rest follow the displayed order).
enum Category {
CATEGORY_DEBUG = 0,
CATEGORY_GRAPHICS = 1,
CATEGORY_CONSOLE = 2,
CATEGORY_COMBAT = 3,
CATEGORY_GAME = 4,
CATEGORY_DEFAULT = 5,
CATEGORY_NET = 6,
CATEGORY_SOUND = 7,
CATEGORY_GM = 8,
};
// Registers a console command. `name` and `description` are stored BY
// POINTER (the engine doesn't copy them), so pass string literals or
// otherwise process-lifetime storage. Dedup-safe: re-registering the
// same name is a harmless no-op, so it's fine to call from a hook that
// fires more than once. Safe any time after engine boot — the command
// table self-initializes on first use. `description` may be nullptr.
void RegisterCommand(const char *name, CommandHandler handler, int category,
const char *description);
// Writes a line to the console output buffer. No-ops cleanly when the
// console isn't active, so it's always safe to call.
void Write(const char *line);
} // namespace Console
// Self-registration for API modules. Each module .cpp declares a file-scope
// `static const Game::ModuleAutoRegister _r{&RegisterLuaFunctions};`, which
// chains itself onto a global list at DLL-load time. `RunModuleRegistrations`
+344 -9
View File
@@ -405,6 +405,23 @@ enum Offsets {
// descriptor at +0x114 — these are sibling classes under CGObject
// with class-specific descriptor offsets.
OFF_UNIT_DESCRIPTOR = 0x110,
// CGCreature client-side creature-data cache row. Populated by the
// engine when an NPC GUID becomes visible (the same row the
// `CreatureCache` SMSG fills with the templated NPC's name,
// subname, etc.). NULL for unsynced units or for player CGUnits
// (players don't have a row in this cache — their name comes from
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
//
// Layout of the cache row:
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
@@ -610,6 +627,37 @@ enum Offsets {
// tick count in EAX).
FUN_OS_TICKCOUNT_MS = 0x0042B790,
// Effective cast time (ms) for a spell, fully resolved:
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/,
// int flag /*0 = clamp negatives to 0*/)`. Computes
// SpellCastTimes base + level scaling, applies the cast-time
// SpellMod (op 10, via FUN_006e6af0), then multiplies by the
// caster's cast-speed/haste mod (descriptor +0x22c). This is the
// exact helper the engine's own cast-start path (FUN_006ec910)
// uses for the cast-bar end time, so reading it keeps our
// UnitCastingInfo end time in lockstep with the engine. 0 for
// instant / no-cast-time spells.
FUN_GET_CAST_TIME = 0x006E3340,
// Effective power cost (mana/rage/energy/…) for a spell:
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/)`.
// Computes ManaCost base + per-level + ManaCostPercent-of-resource,
// applies the descriptor power-cost modifiers, then the cost
// SpellMod (op 0xE / 14). 0xFFFFFFFF on invalid input / no player.
// Read by C_Spell.GetSpellPowerCost (the full helper, like the cast
// time one — beats replicating op 14 via Spell::Mod since it also
// folds in the descriptor cost mods).
FUN_GET_SPELL_COST = 0x006E31B0,
// Effective spell/channel duration (ms):
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
// base + level scaling (capped at the row's max), then the duration
// SpellMod (op 1). Same `unit` selector as the cast-time/cost helpers.
// Used for UnitChannelInfo's channel end time. Note: takes the spell
// RECORD pointer, not a spellID.
FUN_GET_SPELL_DURATION = 0x006EA000,
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
@@ -634,6 +682,49 @@ enum Offsets {
OFF_SPELLDURATION_PER_LEVEL_MS = 0x08,
OFF_SPELLDURATION_MAX_MS = 0x0C,
// `SMSG_SPELL_GO` inner handler — `void __fastcall(uint64_t *itemGUID,
// uint64_t *casterGUID, uint32_t spellId, CDataStore *packet)`. The
// engine has already decoded item/caster/spell into args; the packet
// read cursor sits at the post-header body (castFlags, hit/missed
// target lists, target mask). This is the ONLY place the client sees
// an aura's *caster* — the unit aura array stores spell IDs but never
// who applied them. `Aura::Source` co-hooks this (alongside nampower,
// which detours the same function) to cache `(targetGuid, spellId) →
// casterGuid` for `C_UnitAuras` sourceUnit + non-player expiration.
// Packet layout mirrored from nampower's SpellGoHook (verified on the
// same Octo/Turtle 1.12.1 client). Per-cast frequency, not per-frame.
FUN_SPELL_GO = 0x006E7A70,
// SMSG packet handler that processes `SMSG_SPELL_START` (opcode
// `0x131`) among others — `int __fastcall(uint32_t unk, uint32_t
// opCode, uint32_t unk2, CDataStore *packet)`. For `0x131` the body is
// `itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16),
// castTime(u32), targetMask, …`. This is the only place the client sees
// *another* unit's cast with a server-authoritative cast time, so
// `Spell::Cast` co-hooks it (gated on `opCode == 0x131`) to back
// remote-unit `UnitCastingInfo` / `UnitChannelInfo`. Mirrored from
// nampower's SpellStartHandlerHook.
FUN_SPELL_START_HANDLER = 0x006E7640,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
// including proc/triggered auras that never produce an `SMSG_SPELL_GO`
// (Shadow Weaving, etc.). `Aura::Source` co-hooks it to stamp
// expiration for those — it carries no caster, so it fills timing only
// and never overwrites an entry `SpellGo` already owns. Per-aura-change
// frequency. nampower hooks the same function (its BUFF/DEBUFF events).
FUN_ON_AURA_ADDED = 0x006123F0,
// `CGUnit_C::OnAuraStacksChanged` — `__thiscall void(CGUnit *unit, int
// slot, uint8_t stackCount)` (`__fastcall(unit, edx_unused, slot,
// stackCount)`). Fires when an existing aura's stack count changes
// without a fresh add — e.g. Shadow Weaving climbing 2→3. The slot's
// spellID is read from the unit aura array (`OnAuraAdded` gets it as an
// arg; here only the slot is given). `Aura::Source` co-hooks it to
// re-stamp expiration so stacking-DoT refreshes are picked up.
FUN_ON_AURA_STACKS_CHANGED = 0x00612450,
// CGPlayer-side sub-struct, allocated for any player-controlled
// unit (local self, target, party, raid, inspect targets — all of
// them). Holds player-specific data that's *not* in the broadcast
@@ -1400,6 +1491,10 @@ enum Offsets {
VAR_CHRCLASSES_COUNT = 0x00C0DEF8,
OFF_CHRCLASSES_NAMES = 0x14,
OFF_CHRCLASSES_FILENAME = 0x38,
// SpellFamilyName for the class — the gate the SpellMod system
// matches a spell's OFF_SPELL_RECORD_FAMILY_NAME against. Read by
// FUN_006e6ca0 (`DAT_00cecaac = ChrClasses[classID] + 0x3c`).
OFF_CHRCLASSES_SPELL_FAMILY = 0x3C,
// ChrRaces.dbc — standard 5-DWORD class shape at 0x00C0DED8,
// records-pointer at +0x08, count at +0x0C. 29 columns,
@@ -1930,6 +2025,68 @@ enum Offsets {
FUN_DBCACHE_QUEST_GET_RECORD = 0x00562A40,
VAR_QUEST_LOG_SELECTED_QUEST_ID = 0x00BB7480,
// Creature cache (creaturecache.wdb, SMSG_CREATURE_QUERY_RESPONSE).
// One instance of the generic client query-cache class; constructed
// by the cache factory (FUN_00554b40 -> FUN_00556060 with sig
// 'BOMW', "creaturecache.wdb", recordSize 0x60).
VAR_CREATURE_CACHE = 0x00C0E354,
// Creature-cache `_GetRecord` — `__thiscall(cache, id, const u64 *guid,
// void *callback, void *userData, char dedup)`. Returns the data
// block (entry+0x18) when loaded; with `callback==0` it only peeks
// (no query), with a non-null callback it fires the network query on
// a miss. Class-specific (reads the loaded flag at entry+0x50);
// gameobjects are a sibling class with a different layout and their
// own `_GetRecord` (FUN_GAMEOBJECT_GET_RECORD). Verified via
// FUN_00604600 (`FUN_00556aa0(creatureID, &guid, cb, 0, 0)`).
FUN_CREATURE_GET_RECORD = 0x00556AA0,
// Creature-cache response parser — `__thiscall(cache, packetReader,
// flag)`. Fills the entry data block, sets the loaded flag (entry+0x50),
// and walks the entry's pending-callback list. The creature SMSG
// handler `FUN_005550E0` calls it with ecx=the creature cache.
// `Cache::QueryLoad` hooks it to fire CREATURE_DATA_LOAD_RESULT. The
// item/quest caches have their own parsers, so it's conflict-free.
FUN_CREATURE_QUERY_RESPONSE = 0x00556E20,
// Creature-query record field offsets, within the data block returned
// by FUN_CREATURE_GET_RECORD. Anchored by rank@+0x20 (read by the
// classification helper FUN_00605620 as `[block+0x20]`) and the
// wire/WDB field order (Name[4], SubName, typeFlags, type, family,
// rank, unk, petSpellDataId, displayId), cross-checked against real
// creaturecache.wdb rows (Misthoof Stag 61332 -> type 1; Nordrassil
// Nymph 61582 -> type 7, rank 1). Strings are char* (the fixed 0x60
// block holds pointers, not the WDB's inline strings).
OFF_CREATURE_NAME = 0x00, // char *Name[4]; [0] is the primary name
OFF_CREATURE_SUBNAME = 0x10, // char *SubName (title); "" if none
OFF_CREATURE_TYPE = 0x18, // CreatureType (1=Beast, 7=Humanoid, ...)
OFF_CREATURE_FAMILY = 0x1C, // CreatureFamily
OFF_CREATURE_RANK = 0x20, // 0=normal,1=elite,2=rareelite,3=worldboss,4=rare
OFF_CREATURE_DISPLAYID = 0x2C, // creature model display ID
// GameObject cache (gameobjectcache.wdb, SMSG_GAMEOBJECT_QUERY_RESPONSE).
// Sibling cache class to creature (constructed by FUN_00554B90 ->
// FUN_00557bd0, sig 'BOGW', recordSize 0x5E) — same struct shape but a
// different vtable (0x0080913C) and a LARGER entry (loaded flag at
// entry+0x98, not +0x50), so it has its own `_GetRecord` and parser.
VAR_GAMEOBJECT_CACHE = 0x00C0E318,
// GO `_GetRecord`, same signature as the creature one. Verified via
// FUN_0062dc10 (`FUN_00558560(goEntry, &guid, 0,0,0)` then reads the
// GO name at `[block+0x08]`).
FUN_GAMEOBJECT_GET_RECORD = 0x00558560,
// GO response parser — `__thiscall(cache, packetReader, flag)`. The GO
// SMSG handler `FUN_00555100` calls it with ecx=the GO cache. Distinct
// from the creature parser (sets loaded flag at entry+0x98), so
// `Cache::QueryLoad` hooks it separately to fire GAMEOBJECT_DATA_LOAD_RESULT.
FUN_GAMEOBJECT_QUERY_RESPONSE = 0x005588E0,
// GO-query record field offsets, within the data block returned by
// FUN_GAMEOBJECT_GET_RECORD. Wire/WDB order: type, displayId, Name[4],
// then a 24-int data[] array. name@+0x08 verified (FUN_0062dc10 reads
// `[block+0x08]` as the name); type@+0x00 / displayId@+0x04 follow from
// the field order, cross-checked against real gameobjectcache.wdb rows
// (Windrunner type 15 displayId 7087; Mesa Elevator 47297 type 11
// displayId 360).
OFF_GAMEOBJECT_TYPE = 0x00, // GameObjectType (0=door,3=chest,…)
OFF_GAMEOBJECT_DISPLAYID = 0x04, // model display ID
OFF_GAMEOBJECT_NAME = 0x08, // char *Name[4]; [0] is the primary name
// Action bar slot table — `uint[120]` (max slot index `< 0x78`).
// Each entry is a packed action descriptor; top 4 bits are the type
// tag, low 28 bits are the payload. Tags:
@@ -2364,6 +2521,41 @@ enum Offsets {
VAR_SPELL_RECORDS = 0x00C0D788, // SpellRecord *records[spellID]
VAR_SPELL_RECORD_COUNT = 0x00C0D78C, // max spellID
// SpellMechanic.dbc — maps a SpellMechanic ID to its localized
// name. Standard 5-DWORD DBC instance at 0x00C0D7BC; records ptr at
// +0x08, count at +0x0C (see docs/DBCs.md). Records are 1-based
// (records[mechanicID]); the engine only ever uses the table for
// existence/bounds checks (FUN_006e9ca0, FUN_00612df0, FUN_006e8eb0
// case 0x8d all do `records[id] != 0`), never reading the name — so
// the name offset below follows the canonical 2-column
// (ID + localized Name) DBC schema rather than an engine-reader anchor.
VAR_SPELLMECHANIC_RECORDS = 0x00C0D7C4, // SpellMechanicRecord *records[mechanicID]
VAR_SPELLMECHANIC_COUNT = 0x00C0D7C8, // max mechanic ID
OFF_SPELLMECHANIC_NAME = 0x04, // char *name[9], locale-indexed
// SpellItemEnchantment.dbc — the table a weapon/item enchant ID
// (e.g. the enchantID from `C_Item.GetWeaponEnchantInfo`) indexes.
// Records-ptr at instance +0x08, count at +0x0C (instance 0x00C0D7D0).
// 24-DWORD records (recordSize 0x60). Layout fully verified by
// parsing the on-disk SpellItemEnchantment.dbc against known records
// (Crusader 1900: Type[0]=1, Arg[0]=20007 "Holy Strength";
// Sharpened+3 id 13: Type[0]=2, Amount[0]=3; Reinforced Armor+8 id 15:
// Type[0]=4, Amount[0]=8). OFF_..._NAME (+0x34) is cross-confirmed by
// two engine readers (FUN_00496170 / FUN_00495d60) that push
// `[rec + 0x34 + VAR_LOCALE_INDEX*4]` as the display name.
// Type[i] : ITEM_ENCHANTMENT_TYPE — 1=combat-proc spell,
// 2=damage, 3=equip spell, 4=resistance, 5=stat,
// 6=totem, 7=use spell. 0 = empty slot.
// Amount[i] : magnitude for damage/resist/stat types (min; vanilla
// min==max). 0 for spell types (value lives in the spell).
// Arg[i] : spellID for spell types (1/3/7); stat index for STAT.
VAR_SPELLITEMENCHANT_RECORDS = 0x00C0D7D8, // SpellItemEnchantmentRecord *records[enchantID]
VAR_SPELLITEMENCHANT_COUNT = 0x00C0D7DC, // max enchant ID
OFF_SPELLITEMENCHANT_TYPE = 0x04, // int Type[3]
OFF_SPELLITEMENCHANT_AMOUNT = 0x10, // int Amount[3] (EffectPointsMin)
OFF_SPELLITEMENCHANT_ARG = 0x28, // int EffectArg[3]
OFF_SPELLITEMENCHANT_NAME = 0x34, // char *name[8], locale-indexed
// Spell.dbc School field — 0-based integer at record +0x04.
// Verified empirically against Fireball (133) → School=2 (Fire)
// and Frostbolt (116) → School=4 (Frost) on Octo 1.12.1. Vanilla
@@ -2389,6 +2581,35 @@ enum Offsets {
VAR_SPELL_CAST_TIMES_RECORDS = 0x00C0D878, // SpellCastTimes.dbc
VAR_SPELL_CAST_TIMES_COUNT = 0x00C0D87C,
// SpellRadius.dbc — AOE radius records, indexed by a spell's
// EffectRadiusIndex (see OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX). Record
// shape: { id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }.
// Verified by decompiling FUN_006e6350 (the engine's effective-radius
// helper), which reads `radiusRec[+4]` as the base radius and
// `radiusRec[+8] * casterLevel` as the per-level term. Read by
// GetSpellRadius (base radius only — no level scaling).
VAR_SPELL_RADIUS_RECORDS = 0x00C0D7B0,
VAR_SPELL_RADIUS_COUNT = 0x00C0D7B4,
OFF_SPELL_RADIUS_VALUE = 0x04, // base radius, f32
// SpellMod (talent / item) accumulators for the LOCAL PLAYER. Two
// parallel tables of int32, indexed [familyFlagBit][op]: flat adds at
// VAR_SPELLMOD_FLAT_TABLE, percent adds at VAR_SPELLMOD_PCT_TABLE.
// Per-bit stride is 0x74 (29 ops); the op is the dword index (op*4
// within a slot). A spell's per-effect value is modified by summing,
// over the bits set in its SpellFamilyFlags, flat[bit][op] and
// pct[bit][op], then `value = (value + flat) * (100 + pct) * 0.01`.
// Gated on the spell's SpellFamilyName matching the player's class
// family. Decompiled from FUN_006e6b30 / FUN_006e6bf0 (the engine's
// ApplySpellMod), which the radius helper FUN_006e6350 invokes with
// op 6. These are single global instances = the local player's mods
// (vanilla only tracks spell mods for the local player).
VAR_SPELLMOD_FLAT_TABLE = 0x00CEAD60,
VAR_SPELLMOD_PCT_TABLE = 0x00CECB30,
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
// names like "Magic", "Curse", "Disease", "Poison". Used by
// `Script_UnitDebuff` to fill its third return value. Standard
@@ -2727,8 +2948,12 @@ enum Offsets {
// trailing zeros, hands the buffer to the caller. Caller frees
// via `FUN_STORM_SMEM_FREE`.
//
// __cdecl int FileRead(
// int unused, // always 0 from engine call sites
// __stdcall int FileRead( // callee cleans 28 bytes (RET 0x1C);
// // NOT __cdecl — see src/addons/Embedded.cpp
// int archive, // 0 = merged VFS; or an archive
// // handle to read from one MPQ
// // (e.g. FUN_00648FB0 reads its
// // "(listfile)" this way)
// const char *path, // "Interface\AddOns\X\Y.lua"
// void **outBuf, // Storm-allocated content
// size_t *outSize, // optional, may be NULL
@@ -2743,6 +2968,85 @@ enum Offsets {
// normal `SMemFree` works without us tracking lifetime.
FUN_FILE_READ = 0x00648620,
// MPQ file enumerator — walks every mounted archive's `(listfile)`
// and invokes a callback for each entry whose path begins with a
// given prefix. This is the engine's flat, recursive name walk (the
// `SFileEnumFiles` analog), NOT a per-directory find. It's the
// function the macro-icon loader `FUN_LOAD_MACRO_ICONS` uses to
// surface MPQ-baked icons under `Interface\Icons\`.
//
// void __fastcall MpqEnumFiles(
// int archiveSelector, // ecx — archive-set selector;
// // the loader passes 6.
// // Covers the primary
// // mounted archives plus
// // one selected extra.
// const char *pathPrefix, // edx — e.g. "Interface\" —
// // only listfile entries
// // under this prefix are
// // reported.
// FileCb callback, // per-file callback (below)
// void *userParam); // loader passes 0
//
// The callback is `int __fastcall(const char *fullPath /*ecx*/)`;
// `fullPath` is the entry's full archive-relative path (e.g.
// "Interface\FrameXML\Foo.lua"). Returning 0 STOPS enumeration —
// return non-zero to keep going (note: the OPPOSITE of the disk
// find at `FindFirstFileW`). The continue/stop sense is verified in
// the per-archive walker `FUN_00648FB0`, whose loop does
// `if (... && callback() == 0) break;`.
//
// Internally: `FUN_00401470` loops the archive-handle array
// (`DAT_008826BC`, count `DAT_00882740`) calling the per-archive
// listfile walker `FUN_00648FB0`, which reads each archive's
// `(listfile)` via `FUN_FILE_READ`, splits on CR/LF, prefix-filters
// via a trampoline, and dispatches the callback. Used by
// `Interface::Export` (`ExportInterfaceFiles`).
//
// NB: the sibling `FUN_0042AD10` is a DISK-only walker (it resolves
// to kernel32 `FindFirstFileW` and never sees MPQ contents) — don't
// confuse the two. The macro `Icons.cpp` callback labels have the
// disk/MPQ roles swapped; this entry is the authoritative note.
FUN_MPQ_ENUM_FILES = 0x00401470,
// Console-command registrar — the vanilla equivalent of 4.3.4's
// `FUN_00654c90`. Registers a developer-console command (the `~`
// console you get when launching with `-console`).
//
// int __fastcall RegisterConsoleCommand(
// const char *name, // ecx — command name; stored BY
// // POINTER (no copy), so must
// // have static lifetime
// Handler handler, // edx — see below
// int category, // help-grouping category id
// const char *description);// help text, or NULL
// Returns 1 on success, 0 if a command with that name already
// exists (dedup-safe — repeat calls are harmless no-ops). The
// command node is never freed for a static-literal name, so we
// don't need SStrDup the way the event table does.
//
// The handler is `int __fastcall(void *unused /*ecx*/, const char
// *args /*edx*/)`; `args` is the text after the command name
// (`*args == '\0'` when invoked bare). Return 1. Categories seen in
// the binary: graphics=1, help=2, misc=5 (we use 5, matching
// 4.3.4's `ExportInterfaceFiles`); the value is cosmetic (only
// affects `help` grouping) and unvalidated by the registrar.
//
// Self-initializes the command hash table on first use, so it is
// safe to call any time after the engine's own console subsystem
// boots (well before our glue / load-script hooks fire). Verified
// via the bulk graphics registrar `FUN_0066F6C0` and the `help`
// command's own registration in `FUN_006400E0`.
FUN_CONSOLE_COMMAND_REGISTER = 0x0063F9E0,
// `ConsoleWrite(const char *line /*ecx*/, int colorFlag /*edx*/)` —
// appends a line to the developer console's output buffer. No-ops
// cleanly when the console isn't active (gated on the console-init
// globals `DAT_00C4EC1C` / `DAT_00C4EC34`), so it's safe to call
// unconditionally from a console-command handler. `colorFlag` 0 =
// default. Used by `Interface::Export` for "wrote N files" feedback.
FUN_CONSOLE_WRITE = 0x0063CB50,
// TOC parser — `__fastcall(this=addonName)`. Already documented in
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
// the addon is already in the hash table, returns immediately
@@ -3286,13 +3590,19 @@ enum Offsets {
// Bits 4-7 — WASD movement-key state. Each `MoveForwardStart`
// / `StrafeLeftStart` / etc. pushes one of these as the bit
// mask into the engine's button-press handler. Bit `0x1000` is
// the autorun-active flag (set by `ToggleAutoRun` and the
// both-mouse-buttons combo). `INPUT_FLAGS_MOVING_ANY` is the
// engine's own "is the user currently inputting translational
// movement" mask, used to drive `PLAYER_STARTED_MOVING` —
// matches modern's "STOPPED fires on key release, even
// mid-air" semantics because no physics/airborne bit is
// involved.
// the autorun-active flag (set by `ToggleAutoRun`).
// `INPUT_FLAGS_MOVING_ANY` is the engine's own "is the user
// currently inputting translational movement via keys/autorun"
// mask, used to drive `PLAYER_STARTED_MOVING` — matches modern's
// "STOPPED fires on key release, even mid-air" semantics because
// no physics/airborne bit is involved.
//
// NOTE: the "hold both mouse buttons to run forward" gesture is
// NOT in this mask. The button handler (`FUN_00514840`) drives
// that forward move directly via `FUN_005103e0` and explicitly
// *clears* the autorun bit when both buttons go down. Detect it
// from `INPUT_FLAG_MOUSELOOK & INPUT_FLAG_FREE_LOOK` both held
// instead — see `Player::InputEvents`.
INPUT_FLAG_MOVE_FORWARD = 0x10,
INPUT_FLAG_MOVE_BACKWARD = 0x20,
INPUT_FLAG_STRAFE_LEFT = 0x40,
@@ -3693,6 +4003,31 @@ enum Offsets {
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
// radius. Read by GetSpellRadius.
OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX = 0x160, // int32[3]
// SpellFamily classification, used by the SpellMod system (see
// VAR_SPELLMOD_*). Verified from FUN_006e6b30: SpellFamilyName at
// +0x280 (gates whether the player's class mods apply), 64-bit
// SpellFamilyFlags at +0x284 (selects which familyFlagBit rows to
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
// Spell.dbc Mechanic field — the spell-level SpellMechanic ID (→
// SpellMechanic.dbc), 0 = none. Field 5 of the record
// (ID, School, Category, castUI, Dispel, Mechanic), which lands at
// +0x14 given Attributes (field 6) is verified at +0x18. Cross-
// confirmed by FUN_006e9ca0 (the mechanic-immunity check), which
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
OFF_SPELL_RECORD_MECHANIC = 0x14,
SPELL_AURA_MOD_SHAPESHIFT = 36,
SPELL_AURA_MOUNTED = 78,
+30 -13
View File
@@ -60,6 +60,13 @@ Data::Filter ParseFilter(const char *filter) {
return Data::Filter::Helpful;
}
// The `PLAYER` filter token restricts results to auras the local player
// cast. No other modern token contains the substring "PLAYER", so a plain
// substring test is safe.
bool HasPlayerFilter(const char *filter) {
return filter != nullptr && strstr(filter, "PLAYER") != nullptr;
}
const char *ArgUnit(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
@@ -82,9 +89,9 @@ 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) {
Data::Filter filter, bool playerOnly = false) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -101,7 +108,8 @@ int __fastcall Script_GetAuraDataByIndex(void *L) {
Game::Lua::PushNil(L);
return 1;
}
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr));
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr),
HasPlayerFilter(filterStr));
}
int __fastcall Script_GetBuffDataByIndex(void *L) {
@@ -139,7 +147,8 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID), fp);
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -163,7 +172,8 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellName(unit, spellName, fp);
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
return 1;
@@ -192,7 +202,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) {
Data::Filter filter, int &nextKey, bool playerOnly) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
@@ -202,6 +212,8 @@ 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))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
Game::Lua::SetTable(L, outerIdx);
@@ -218,14 +230,19 @@ int __fastcall Script_GetUnitAuras(void *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;
int nextKey = 1;
if (filterStr == nullptr) {
// No filter — return both ranges.
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey);
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey);
} else {
AppendRangeToArray(L, unit, 1, ParseFilter(filterStr), nextKey);
}
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
return 1;
}
+87 -11
View File
@@ -15,7 +15,10 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -38,6 +41,13 @@ const uint8_t *Descriptor(const uint8_t *unit) {
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
// Keys into the `Aura::Source` caster/expiration cache. Thin alias for the
// shared `Unit::Identity::GuidForObject` so the lookup here and the
// OnAuraAdded hook in `Aura::Source` read the GUID the same way.
uint64_t UnitGuid(const uint8_t *unit) {
return Unit::Identity::GuidForObject(unit);
}
const uint8_t *SpellRecord(uint32_t spellID) {
return DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT, spellID);
@@ -219,7 +229,20 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
return IsVisible(SpellRecord(spellID));
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter) {
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))
return false;
return casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
const int start = (filter == Filter::Harmful)
@@ -232,6 +255,8 @@ 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))
continue;
if (++matches == oneBasedIndex)
return slot;
}
@@ -239,7 +264,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter) {
}
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter) {
const Filter *filter, bool playerOnly) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -251,14 +276,17 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (ReadSpellID(unit, slot) == spellID)
return slot;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
return slot;
}
return -1;
}
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter) {
const Filter *filter, bool playerOnly) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -271,8 +299,11 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
if (!IsSlotPopulated(unit, slot))
continue;
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name != nullptr && std::strcmp(name, spellName) == 0)
return slot;
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
continue;
return slot;
}
return -1;
}
@@ -374,8 +405,52 @@ void Push(void *L, const uint8_t *unit, int slot) {
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache
// (`Aura::Source`), captured at cast time. The vanilla descriptor has
// neither. `sourceUnit` applies to any unit; `expirationTime` only
// fills in here when the player-buff table above didn't — that table is
// authoritative for the player's own auras, the cache covers everyone
// else. Both are best-effort: a miss (aura predates login, or the cast
// wasn't observed) leaves the modern-truthful defaults untouched.
const char *sourceUnit = nullptr;
const char *sourceGUID = nullptr;
char tokenBuf[32];
char guidBuf[Guid::STRING_SIZE];
if (spellID != 0) {
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs,
&durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
// Prefer the applied (caster-modified) duration so `duration`
// stays consistent with `expirationTime` — otherwise a talented
// DoT (e.g. Improved Shadow Word: Pain) shows remaining time
// exceeding the base `duration`.
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
if (casterGuid != 0) {
// `sourceUnit` is a token (nil when the caster maps to no
// current token); `sourceGUID` is the raw "0x..." GUID and
// is set whenever we have a caster — a ClassicAPI extension
// (not in retail AuraData) that survives the caster leaving
// token range and doubles as a unit token under SuperWoW.
sourceUnit = Unit::Identity::TokenFromGUID(
casterGuid, tokenBuf, sizeof tokenBuf);
sourceGUID = Guid::FormatAsString(casterGuid, guidBuf,
sizeof guidBuf);
}
}
}
Game::Lua::SetFieldNumber(L, "duration", duration);
Game::Lua::SetFieldNumber(L, "expirationTime", expirationTime);
if (sourceUnit != nullptr)
Game::Lua::SetFieldString(L, "sourceUnit", sourceUnit);
if (sourceGUID != nullptr)
Game::Lua::SetFieldString(L, "sourceGUID", sourceGUID);
Game::Lua::SetFieldNumber(L, "charges", 0);
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
@@ -391,10 +466,11 @@ void Push(void *L, const uint8_t *unit, int slot) {
Game::Lua::SetFieldBool(L, "shouldConsolidate", false);
Game::Lua::SetFieldBool(L, "isRaid", false);
// `sourceUnit`, `auraInstanceID`, `points` deliberately omitted
// — modern returns nil for those when they don't apply, and
// Lua reading a missing key yields nil, so the table doesn't
// need an explicit entry.
// `sourceUnit` / `sourceGUID` are set above when the `Aura::Source`
// cache has a caster for this aura; left unset (→ nil) on a miss.
// `auraInstanceID` and `points` are deliberately omitted — modern
// returns nil for those when they don't apply, and Lua reading a
// missing key yields nil, so the table doesn't need an explicit entry.
}
} // namespace Aura::Data
+21 -8
View File
@@ -47,22 +47,29 @@ bool IsSlotPopulated(const uint8_t *unit, int slot);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
// matching `filter`. Returns -1 if no such aura. Walks
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
// UNIT_AURA_TOTAL-1 for Harmful.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter);
// 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);
// 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).
// Returns -1 if not found.
// `playerOnly` restricts to player-cast auras. Returns -1 if not found.
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter);
const Filter *filter, bool playerOnly = false);
// 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);
const Filter *filter, bool playerOnly = false);
// 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);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
@@ -85,12 +92,18 @@ const char *DispelName(uint32_t dispelTypeID);
//
// Populates these fields with real data:
// name, icon, applications, spellId, dispelName,
// isHelpful, isHarmful, timeMod
// isHelpful, isHarmful, timeMod, duration
//
// And these from the `Aura::Source` SMSG_SPELL_GO cache when available
// (else their inapplicable default):
// expirationTime (player: engine buff table; others: cache; 0 on miss),
// sourceUnit (caster token from the cache; nil on miss),
// sourceGUID (caster "0x..." GUID string; nil on miss — a ClassicAPI
// extension, not in retail AuraData)
//
// And these with vanilla-truthful defaults (matches modern's
// "field present but inapplicable" semantics):
// duration=0, expirationTime=0, charges=0, maxCharges=0,
// sourceUnit=nil, auraInstanceID=nil, points=nil,
// charges=0, maxCharges=0, auraInstanceID=nil, points=nil,
// isStealable=false, isBossAura=false, isFromPlayerOrPlayerPet=false,
// isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, canApplyAura=false,
+263
View File
@@ -0,0 +1,263 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
// See `Source.h` for the contract. This is the backend: a co-hook on
// `FUN_SPELL_GO` that mirrors nampower's `SpellGoHook` parse to capture the
// caster + duration of every aura-applying cast, and a small fixed-size
// cache `Aura::Data::Push` reads from.
#include "Source.h"
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::Source {
namespace {
using Net::CDataStore;
// A cast applies an aura iff any of its three effects names an aura. The
// EffectApplyAuraName[3] int32 array sits at `+0x16C` (already used by the
// shapeshift / mechanic-immunity code). Gating on this keeps pure-damage
// and utility casts out of the cache.
bool SpellAppliesAura(const uint8_t *spellRecord) {
if (spellRecord == nullptr)
return false;
const auto *auraNames = reinterpret_cast<const int32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (auraNames[i] != 0)
return true;
}
return false;
}
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Server-authoritative duration (ms). When the local player is the caster
// we let the engine fold in the player's duration modifiers (skipMod = 0);
// for any other caster we take the unmodified base (skipMod = 1) since we
// only have the local player's mod tables. `unit = 0` selects the player
// mod context per FUN_GET_SPELL_DURATION's signature.
uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord,
int unit, char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(
spellRecord, 0, casterIsPlayer ? 0 : 1);
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- Cache ---------------------------------------------------------------
struct Entry {
uint64_t targetGuid;
uint64_t casterGuid;
uint32_t spellId;
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
bool used;
};
constexpr int kCacheSize = 256;
Entry g_cache[kCacheSize];
int g_writeCursor = 0;
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
// no caster, and it must not clobber an entry SpellGo already owns (that
// would replace talented timing with the unmodified base), so it skips
// entries that already carry a caster.
void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
uint32_t expirationMs, uint32_t durationMs, bool fromCast) {
if (targetGuid == 0 || spellId == 0)
return;
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
if (!fromCast && e.casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + timing
if (casterGuid != 0)
e.casterGuid = casterGuid;
e.expirationMs = expirationMs;
e.durationMs = durationMs;
return;
}
}
// Take a free slot, else an expired one, else evict round-robin.
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
return;
}
}
Entry &slot = g_cache[g_writeCursor];
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, true};
}
// Drop entries whose timed aura has elapsed so the table doesn't fill with
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
// until overwritten.
void OnWorldTick() {
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
// The hit-target list is all we need: each entry is a unit the cast landed
// on, which is exactly where an aura gets applied. We deliberately stop
// before the missed-target list / target mask — the post-mask "intended"
// target is redundant with the hit list for aura purposes, and skipping it
// 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;
if (caster == 0 || spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true);
return;
}
for (int i = 0; i < numTargets; ++i)
Store(targets[i], spellId, caster, expirationMs, durationMs, true);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
reinterpret_cast<void *>(&SpellGo_h),
reinterpret_cast<void **>(&g_origSpellGo)};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
// Stamp expiration for an aura that just landed/refreshed on `unit`. Used by
// both the add and stack-change hooks. No caster is available from these
// paths, so it stamps timing only with `fromCast=false` — Store skips any
// entry SpellGo already owns, so a directly-cast aura keeps its talented
// timing. Base (unmodified) duration is the best estimate without a caster.
void StampApplication(void *unit, uint32_t spellId) {
if (spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
if (unitGuid == 0)
return;
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
/*fromCast*/ false);
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraAdded_t g_origOnAuraAdded = nullptr;
void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId);
}
const Game::HookAutoRegister _hookAuraAdded{
Offsets::FUN_ON_AURA_ADDED, reinterpret_cast<void *>(&OnAuraAdded_h),
reinterpret_cast<void **>(&g_origOnAuraAdded)};
// OnAuraStacksChanged — an existing aura's stack count changed (e.g. Shadow
// Weaving climbing). Only the slot is given, so read the spellID back from
// the unit's aura array. Re-stamps expiration so stacking refreshes count.
using OnAuraStacksChanged_t = void(__fastcall *)(void *unit, void *edx,
int slot, uint8_t stackCount);
OnAuraStacksChanged_t g_origOnAuraStacksChanged = nullptr;
void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
uint8_t stackCount) {
g_origOnAuraStacksChanged(unit, edx, slot, stackCount);
StampApplication(unit, Aura::Data::ReadSpellID(
static_cast<const uint8_t *>(unit), slot));
}
const Game::HookAutoRegister _hookAuraStacks{
Offsets::FUN_ON_AURA_STACKS_CHANGED,
reinterpret_cast<void *>(&OnAuraStacksChanged_h),
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
} // namespace
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs) {
if (unitGuid == 0 || spellId == 0)
return false;
for (const auto &e : g_cache) {
if (e.used && e.targetGuid == unitGuid && e.spellId == spellId) {
*outCaster = e.casterGuid;
*outExpirationMs = e.expirationMs;
*outDurationMs = e.durationMs;
return true;
}
}
return false;
}
} // namespace Aura::Source
+45
View File
@@ -0,0 +1,45 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
#pragma once
#include <cstdint>
// Aura caster / duration cache, fed by a co-hook on `SMSG_SPELL_GO`
// (`FUN_SPELL_GO`). The vanilla unit aura array (`UNIT_FIELD_AURA`) stores
// only spell IDs — it has no record of *who* cast an aura, and no
// cast/expiration timing for any unit but the local player. The one place
// the client ever sees the caster + a server-authoritative duration is the
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
// `(targetGuid, spellId)`, who cast it and when it should expire.
//
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
// to a unit token) and `expirationTime` for non-player units. The cache is
// inherently best-effort: it only knows about casts observed since login,
// and an entry survives until it expires (combat-duration auras) or is
// evicted under pressure. Misses leave the modern-truthful defaults
// (`sourceUnit` nil, `expirationTime` 0) in place — same outcome as before
// this module existed.
namespace Aura::Source {
// Looks up the cached caster + timing for the aura `spellId` currently on
// the unit identified by `unitGuid`. Returns true and fills the out params
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
// duration including the caster's modifiers (talents etc.; 0 = none) — use
// it for the `duration` field so it stays consistent with `expirationTime`;
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
// on a miss or for zero inputs.
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
} // namespace Aura::Source
+5
View File
@@ -12,4 +12,9 @@
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
</Binding>
</Bindings>
+224
View File
@@ -0,0 +1,224 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "cache/QueryLoad.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Cache::QueryLoad {
namespace {
// A cache class's `_GetRecord`. callback=nullptr → pure peek (returns the
// loaded data block or nullptr, no query). Non-null callback → also fires
// the network query on a miss and stores `(callback, userData)` on the
// entry's pending list. dedup=1 so repeat fetches for the same id before
// the response lands don't queue the callback twice.
using GetCacheRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t id,
const uint64_t *guid, void *callback,
void *userData, char dedup);
// A cache class's response parser, which we hook (e.g.
// FUN_CREATURE_QUERY_RESPONSE / FUN_GAMEOBJECT_QUERY_RESPONSE).
using CacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
// Engine callback shape (__stdcall, ret 8) — see Item::Data. We only pass
// it to make `_GetRecord` send the query (the trigger is callback != 0);
// completion is observed via the response hook + tick sweep.
void __stdcall NoOpCallback(void * /*userData*/, int /*success*/) {}
const uint8_t *CacheFetch(uintptr_t getRecord, void *cache, uint32_t id, void *callback) {
auto fn = reinterpret_cast<GetCacheRecord_t>(getRecord);
const uint64_t zeroGuid = 0;
void *userData =
callback ? reinterpret_cast<void *>(static_cast<uintptr_t>(id)) : nullptr;
return fn(cache, id, &zeroGuid, callback, userData, 1);
}
struct Pending {
uint32_t id;
int ticksWaiting;
};
constexpr int kMaxCaches = 8;
constexpr int kMaxPending = 256;
constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many WorldTicks
struct Registered {
void *cache;
const char *eventName;
uintptr_t getRecord; // this cache class's _GetRecord
Pending pending[kMaxPending];
int pendingCount;
};
Registered g_caches[kMaxCaches];
int g_cacheCount = 0;
Registered *Find(void *cache) {
for (int i = 0; i < g_cacheCount; ++i) {
if (g_caches[i].cache == cache)
return &g_caches[i];
}
return nullptr;
}
void Fire(const Registered *r, uint32_t id, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
static_cast<int>(id), success);
}
int FindPending(const Registered *r, uint32_t id) {
for (int i = 0; i < r->pendingCount; ++i) {
if (r->pending[i].id == id)
return i;
}
return -1;
}
void RemovePendingAt(Registered *r, int idx) {
--r->pendingCount;
if (idx != r->pendingCount)
r->pending[idx] = r->pending[r->pendingCount];
}
bool IsLoaded(const Registered *r, uint32_t id) {
return CacheFetch(r->getRecord, r->cache, id, nullptr) != nullptr;
}
// Fire + remove any pending id whose entry is now loaded.
void Sweep(Registered *r) {
for (int i = 0; i < r->pendingCount;) {
if (IsLoaded(r, r->pending[i].id)) {
const uint32_t id = r->pending[i].id;
RemovePendingAt(r, i);
Fire(r, id, true);
continue;
}
++i;
}
}
// Shared post-response dispatch: a parser for `cache` just ran, so sweep
// that cache's pending set (no-op for caches we don't track).
void DispatchSweep(void *cache) {
Registered *r = Find(cache);
if (r != nullptr)
Sweep(r);
}
// One response-handler hook per cache class. Each calls its own original
// trampoline (the parser fills the entry + walks the engine's own
// callback list), then sweeps via the shared dispatch. `__thiscall`
// mimicked as `__fastcall(ecx, edx_unused, …)`.
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
CacheResponse_t g_origCreatureParser = nullptr;
void __fastcall CreatureParser_h(void *cache, void * /*edx*/, void *packetReader, int flag) {
reinterpret_cast<Trampoline_t>(g_origCreatureParser)(cache, nullptr, packetReader, flag);
DispatchSweep(cache);
}
CacheResponse_t g_origGameObjectParser = nullptr;
void __fastcall GameObjectParser_h(void *cache, void * /*edx*/, void *packetReader, int flag) {
reinterpret_cast<Trampoline_t>(g_origGameObjectParser)(cache, nullptr, packetReader, flag);
DispatchSweep(cache);
}
const Game::HookAutoRegister _hookCreature{
Offsets::FUN_CREATURE_QUERY_RESPONSE, reinterpret_cast<void *>(&CreatureParser_h),
reinterpret_cast<void **>(&g_origCreatureParser)};
const Game::HookAutoRegister _hookGameObject{
Offsets::FUN_GAMEOBJECT_QUERY_RESPONSE, reinterpret_cast<void *>(&GameObjectParser_h),
reinterpret_cast<void **>(&g_origGameObjectParser)};
// Timeout + safety net. The hooks cover the happy path; this fires
// failure for ids that never resolve after `kMaxWaitTicks`, and
// re-checks loaded as a backstop for fills observed off the hook path.
void OnWorldTick() {
for (int c = 0; c < g_cacheCount; ++c) {
Registered *r = &g_caches[c];
for (int i = 0; i < r->pendingCount;) {
if (IsLoaded(r, r->pending[i].id)) {
const uint32_t id = r->pending[i].id;
RemovePendingAt(r, i);
Fire(r, id, true);
continue;
}
if (r->pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t id = r->pending[i].id;
RemovePendingAt(r, i);
Fire(r, id, false);
continue;
}
++r->pending[i].ticksWaiting;
++i;
}
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
return;
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
return;
if (g_cacheCount >= kMaxCaches)
return;
g_caches[g_cacheCount].cache = cacheInstance;
g_caches[g_cacheCount].eventName = eventName;
g_caches[g_cacheCount].getRecord = getRecordAddr;
g_caches[g_cacheCount].pendingCount = 0;
++g_cacheCount;
}
bool IsCached(void *cacheInstance, uint32_t id) {
const Registered *r = Find(cacheInstance);
return r != nullptr && IsLoaded(r, id);
}
bool RequestLoad(void *cacheInstance, uint32_t id) {
Registered *r = Find(cacheInstance);
if (r == nullptr || id == 0)
return false;
// Already cached — fire success synchronously (modern RequestLoad
// contract: the completion event fires even on a cache hit).
if (IsLoaded(r, id)) {
Fire(r, id, true);
return true;
}
// Already in flight — one completion event per id.
if (FindPending(r, id) >= 0)
return true;
if (r->pendingCount >= kMaxPending)
return false;
// Send the query (non-null callback is the trigger), then track until
// the response hook / tick fires the event.
CacheFetch(r->getRecord, cacheInstance, id, reinterpret_cast<void *>(&NoOpCallback));
r->pending[r->pendingCount].id = id;
r->pending[r->pendingCount].ticksWaiting = 0;
++r->pendingCount;
return true;
}
} // namespace Cache::QueryLoad
+65
View File
@@ -0,0 +1,65 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Shared async-load machinery for the client query caches that aren't
// item/quest — creature, gameobject, and (potentially) the other
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
// logic, generalized so several caches share one set of code.
//
// These caches are sibling classes: they share the record/entry hash-map
// shape but differ in `_GetRecord` and response-parser per class (the
// creature class reads its loaded flag at entry+0x50, the gameobject
// class at +0x98, etc.). So a module registers BOTH its cache's
// `_GetRecord` address and the dispatcher routes completion by cache
// instance. The dispatcher hooks each class's response parser (MinHook
// allows only one hook per target, but the parsers are distinct
// functions, so hooking creature's and gameobject's separately is fine);
// item/quest have their own parsers and aren't touched.
//
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
// ...
// Cache::QueryLoad::Register(cacheInstance, kEvent,
// Offsets::FUN_CREATURE_GET_RECORD); // at init
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
//
// The completion event fires as `(id: number, success: bool)` — success
// when the cache fills, failure on timeout.
namespace Cache::QueryLoad {
// Register a query cache for async-load tracking. `cacheInstance` is the
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
// `eventName` is a Custom event the caller has reserved via
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
// outlive the process (pass static values). Idempotent — calling again
// for the same cache is a no-op (safe across /reload).
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
// True if `id` is already in the cache (pure peek, no network query).
bool IsCached(void *cacheInstance, uint32_t id);
// Issue an async load for `id`. If already cached, fires the completion
// event with success synchronously. Otherwise sends the query and tracks
// the id until the response lands (or it times out). Returns false only
// if the cache isn't registered or its pending set is full; true
// otherwise (already-cached, already-pending, or newly-tracked).
bool RequestLoad(void *cacheInstance, uint32_t id);
} // namespace Cache::QueryLoad
+138
View File
@@ -0,0 +1,138 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_CreatureInfo.GetCreatureInfoByID(creatureID)` -> table | nil
//
// Reads the client-side creature cache (creaturecache.wdb, fed by
// SMSG_CREATURE_QUERY_RESPONSE) for a creature/NPC by ID and returns:
//
// {
// creatureID = <id>,
// name = "Misthoof Stag", -- primary name
// subName = "", -- title/subtitle ("" if none)
// type = 1, -- CreatureType (1=Beast, 7=Humanoid, …)
// family = 0, -- CreatureFamily
// rank = 0, -- 0=normal,1=elite,2=rareelite,3=worldboss,4=rare
// displayID = 10957, -- model display ID
// }
//
// `GetCreatureInfoByID` is a synchronous *peek*: returns the data only
// if the creature is already cached (seen this session, or loaded from
// creaturecache.wdb at login); nil otherwise. `RequestLoadCreatureByID`
// fetches an uncached creature — it issues the query and fires
// `CREATURE_DATA_LOAD_RESULT(creatureID, success)` when the response
// lands, via the shared `Cache::QueryLoad` dispatcher.
//
// Field offsets verified against the binary (rank read by the engine's
// classification helper as `[block+0x20]`) and real creaturecache.wdb
// rows. See Offsets.h `OFF_CREATURE_*`.
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "event/Custom.h"
#include <cstdint>
namespace Creature::Info {
namespace {
// Completion event for RequestLoadCreatureByID. Reserved at static-init
// so the engine allocates it in its event table; fired (creatureID,
// success) by the Cache::QueryLoad dispatcher.
constexpr const char *kCreatureDataLoadResult = "CREATURE_DATA_LOAD_RESULT";
const Event::Custom::AutoReserve _reserveLoadResult{kCreatureDataLoadResult};
// Generic cache `_GetRecord`. With callback=nullptr it only peeks the
// hash table (no network query) and returns the data block (entry+0x18)
// if the creature is loaded, else nullptr.
using GetCacheRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t id,
const uint64_t *guid, void *callback,
void *userData, char dedup);
const uint8_t *PeekCreature(uint32_t creatureID) {
auto fn = reinterpret_cast<GetCacheRecord_t>(Offsets::FUN_CREATURE_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, creatureID, &zeroGuid, nullptr, nullptr, 0);
}
int __fastcall Script_GetCreatureInfoByID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int creatureID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (creatureID <= 0)
return 0;
const uint8_t *rec = PeekCreature(static_cast<uint32_t>(creatureID));
if (rec == nullptr)
return 0; // not cached -> nil
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_NAME);
const char *subName =
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_SUBNAME);
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_TYPE);
const uint32_t family = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_FAMILY);
const uint32_t rank = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_RANK);
const uint32_t displayID =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_DISPLAYID);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "creatureID", creatureID);
Game::Lua::SetFieldString(L, "name", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "subName", subName);
Game::Lua::SetFieldNumber(L, "type", type);
Game::Lua::SetFieldNumber(L, "family", family);
Game::Lua::SetFieldNumber(L, "rank", rank);
Game::Lua::SetFieldNumber(L, "displayID", displayID);
return 1;
}
// `C_CreatureInfo.RequestLoadCreatureByID(creatureID)` -> bool. Async
// fetch for an uncached creature: issues SMSG_CREATURE_QUERY and fires
// `CREATURE_DATA_LOAD_RESULT(creatureID, success)` when the response
// lands (or on timeout). Fires success synchronously if already cached.
// Returns true if the request was accepted (false for bad input or a
// full pending set) — mirrors C_Item.RequestLoadItemData(ByID).
int __fastcall Script_RequestLoadCreatureByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int creatureID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (creatureID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
auto *cache = reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE);
const bool accepted =
Cache::QueryLoad::RequestLoad(cache, static_cast<uint32_t>(creatureID));
Game::Lua::PushBool(L, accepted);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureInfoByID",
&Script_GetCreatureInfoByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "RequestLoadCreatureByID",
&Script_RequestLoadCreatureByID);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Creature::Info
+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 Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)` -> table | nil
// `C_GameObjectInfo.RequestLoadGameObjectByID(gameObjectID)` -> bool
//
// The gameobject analog of `C_CreatureInfo`. Reads the client-side
// gameobject cache (gameobjectcache.wdb, fed by
// SMSG_GAMEOBJECT_QUERY_RESPONSE) by GO entry ID:
//
// {
// gameObjectID = <id>,
// name = "Mesa Elevator",
// type = 11, -- GameObjectType (0=door,3=chest,…)
// displayID = 360, -- model display ID
// }
//
// `GetGameObjectInfoByID` is a synchronous peek (nil if not cached);
// `RequestLoadGameObjectByID` fetches an uncached GO and fires
// `GAMEOBJECT_DATA_LOAD_RESULT(gameObjectID, success)` when it lands.
//
// The GO cache is a sibling class to the creature cache (its own
// `_GetRecord` and response parser), but it rides the same shared
// `Cache::QueryLoad` dispatcher. Field offsets: name@+0x08 verified
// (FUN_0062dc10 reads `[block+0x08]`), type@+0x00 / displayID@+0x04 from
// the wire/WDB field order. See Offsets.h `OFF_GAMEOBJECT_*`.
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "event/Custom.h"
#include <cstdint>
namespace GameObject::Info {
namespace {
constexpr const char *kGameObjectDataLoadResult = "GAMEOBJECT_DATA_LOAD_RESULT";
const Event::Custom::AutoReserve _reserveLoadResult{kGameObjectDataLoadResult};
using GetCacheRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t id,
const uint64_t *guid, void *callback,
void *userData, char dedup);
// Peek the GO cache (no network query).
const uint8_t *PeekGameObject(uint32_t goID) {
auto fn = reinterpret_cast<GetCacheRecord_t>(Offsets::FUN_GAMEOBJECT_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, goID, &zeroGuid, nullptr, nullptr, 0);
}
int __fastcall Script_GetGameObjectInfoByID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int goID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (goID <= 0)
return 0;
const uint8_t *rec = PeekGameObject(static_cast<uint32_t>(goID));
if (rec == nullptr)
return 0; // not cached -> nil
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_TYPE);
const uint32_t displayID =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_DISPLAYID);
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_GAMEOBJECT_NAME);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "gameObjectID", goID);
Game::Lua::SetFieldString(L, "name", name); // NULL coerced to ""
Game::Lua::SetFieldNumber(L, "type", type);
Game::Lua::SetFieldNumber(L, "displayID", displayID);
return 1;
}
int __fastcall Script_RequestLoadGameObjectByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int goID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (goID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
auto *cache = reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE);
const bool accepted = Cache::QueryLoad::RequestLoad(cache, static_cast<uint32_t>(goID));
Game::Lua::PushBool(L, accepted);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "GetGameObjectInfoByID",
&Script_GetGameObjectInfoByID);
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
&Script_RequestLoadGameObjectByID);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace GameObject::Info
+409
View File
@@ -0,0 +1,409 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `ExportInterfaceFiles art|code` — backport of the 4.3.4 developer-
// console command of the same name. Registered as a real console
// command (the `~` console available when the client is launched with
// `-console`), exactly like retail. Dumps Blizzard's UI files out of
// the mounted MPQ archives onto disk:
//
// code → .lua / .xml / .toc / .xsd → BlizzardInterfaceCode\...
// art → .blp / .tga → BlizzardInterfaceArt\...
//
// `ExportDBCFiles` — ClassicAPI-original companion that dumps every
// `.dbc` table the client loads to `DBFilesClient\`. It unions the MPQ
// `(listfile)` with a scan of `.text` for the DBC path-getter pattern
// (`mov eax, &"DBFilesClient\X.dbc"; ret`) — the latter is the
// authoritative "what does this build load" list, since the master DBC
// init is straight-line (no name table), and it catches the ~18 DBCs the
// listfile doesn't index. See `ScanPathGetters`.
//
// Files land relative to the client's working directory, mirroring the
// retail command's `BlizzardInterface{Code,Art}\<relative-path>`
// layout. On completion it writes a "wrote N files" line back to the
// console.
//
// **How it works (vanilla 1.12 / Octo):**
//
// Like 4.3.4, we enumerate the mounted archives' `(listfile)` — the
// flat, full-path file index — rather than walking directories. The
// vanilla primitive is `FUN_MPQ_ENUM_FILES` (`0x00401470`, the same
// MPQ walk the macro-icon loader uses): it loops the archive handles
// and dispatches a callback per listfile entry under a path prefix
// (here `"Interface\"`). We collect every matching full path (deduped
// across archives), then read each via `FUN_FILE_READ` and write it to
// disk with Win32.
//
// Don't confuse this with `FUN_0042AD10`, the disk-only
// `FindFirstFileW` walker — pointing the export at that yields zero
// MPQ files. See the `FUN_MPQ_ENUM_FILES` note in Offsets.h.
//
// We export the full `Interface\` tree, matching 4.3.4 (extension
// filter only, no path exclusion). That INCLUDES `Interface\AddOns\` —
// the listfile only indexes MPQ-baked files, so those AddOns entries
// are Blizzard's own UI addons (Blizzard_AuctionUI, Blizzard_TalentUI,
// …), which are part of the stock UI source. The player's loose,
// on-disk addons live in no archive's listfile, so they're never
// enumerated and never re-exported.
#include "Game.h"
#include "Offsets.h"
#include <windows.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_set>
#include <vector>
namespace Interface::Export {
namespace {
// Engine's file-content reader (see Offsets::FUN_FILE_READ). __stdcall
// (callee cleans 28 bytes via RET 0x1C — NOT __cdecl; getting this
// wrong corrupts the caller's stack). Allocates a Storm buffer; free
// with SMemFree.
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
unsigned int *outSize, unsigned int extraBytes,
int flag1, int flag2);
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line, int flags);
// MPQ enumerator (see Offsets::FUN_MPQ_ENUM_FILES). The callback
// receives each listfile entry's full path in ecx; returning 0 STOPS
// enumeration, so we return 1 to keep going.
using MpqEnumCb_t = int(__fastcall *)(const char *fullPath);
using MpqEnumFiles_t = void(__fastcall *)(int archiveSelector, const char *pathPrefix,
MpqEnumCb_t callback, void *userParam);
// The archive-set selector the engine's own macro-icon loader passes to
// FUN_00401470 — covers the primary mounted archives plus the selected
// extra. We mirror it.
constexpr int kArchiveSelector = 6;
// Case-insensitive ASCII equality.
bool EqualsCI(const char *a, const char *b) {
for (;; ++a, ++b) {
char ca = *a, cb = *b;
if (ca >= 'A' && ca <= 'Z') ca = static_cast<char>(ca + ('a' - 'A'));
if (cb >= 'A' && cb <= 'Z') cb = static_cast<char>(cb + ('a' - 'A'));
if (ca != cb)
return false;
if (ca == '\0')
return true;
}
}
bool HasExtension(const char *name, const char *const *exts, int count) {
const char *dot = nullptr;
for (const char *p = name; *p; ++p) {
if (*p == '.')
dot = p;
}
if (dot == nullptr)
return false;
for (int i = 0; i < count; ++i) {
if (EqualsCI(dot + 1, exts[i]))
return true;
}
return false;
}
// Create every parent directory of `relPath` (a backslash-separated
// path, e.g. "BlizzardInterfaceCode\FrameXML\Foo.lua"). Each component
// is created relative to the working directory; existing dirs are a
// no-op.
void EnsureParentDirs(const std::string &relPath) {
std::string accum;
for (size_t i = 0; i < relPath.size(); ++i) {
const char c = relPath[i];
if (c == '\\' || c == '/') {
if (!accum.empty())
CreateDirectoryA(accum.c_str(), nullptr);
accum.push_back('\\');
} else {
accum.push_back(c);
}
}
}
bool WriteFileToDisk(const std::string &relPath, const void *data, unsigned int size) {
EnsureParentDirs(relPath);
HANDLE h = CreateFileA(relPath.c_str(), GENERIC_WRITE, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE)
return false;
bool ok = true;
if (size != 0) {
DWORD written = 0;
ok = WriteFile(h, data, size, &written, nullptr) != 0 && written == size;
}
CloseHandle(h);
return ok;
}
const char *const kCodeExts[] = {"lua", "xml", "toc", "xsd"};
const char *const kArtExts[] = {"blp", "tga"};
const char *const kDbcExts[] = {"dbc"};
enum Mode { MODE_CODE, MODE_ART };
std::string LowerCopy(const char *s) {
std::string out;
for (const char *p = s; *p; ++p) {
char c = *p;
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
out.push_back(c);
}
return out;
}
// Collector for the MPQ enumeration callback (and the path-getter scan).
// File-static because FUN_00401470 hands the callback only the path
// string; enumeration is synchronous and single-threaded, so a
// file-static sink is safe.
struct Collector {
const char *const *exts = nullptr;
int extCount = 0;
std::vector<std::string> files;
std::unordered_set<std::string> seen; // lowercased keys — dedup across sources
// Case-insensitive dedup so the listfile and the path-getter scan
// (which may differ in casing) never write the same DBC twice.
void Add(const char *path) {
if (seen.insert(LowerCopy(path)).second)
files.emplace_back(path);
}
};
Collector *g_collector = nullptr;
// MPQ enumeration callback — one call per listfile entry under the
// active prefix. Keep going (return 1) regardless.
int __fastcall CollectCb(const char *fullPath) {
Collector *c = g_collector;
if (c != nullptr && fullPath != nullptr &&
HasExtension(fullPath, c->exts, c->extCount))
c->Add(fullPath);
return 1; // 0 would stop enumeration
}
// --- DBC path-getter scan ---------------------------------------------
//
// The DBC loaders aren't driven by any iterable name table — the master
// init (`FUN_0053f8b0`) is straight-line, calling ~150 per-DBC loaders in
// sequence, each of which calls its own one-instruction path-getter:
//
// B8 <&"DBFilesClient\Foo.dbc"> mov eax, <strVA>
// C3 ret
//
// So the authoritative, dynamic list of "what DBCs does this build load"
// is the set of those getters. We recover it the same way docs/DBCs.md
// did — scan `.text` for the `B8 imm32 C3` pattern and keep matches whose
// immediate points at a `.dbc` string. This catches every loaded table,
// including the ~18 the MPQ `(listfile)` doesn't index.
// Build-specific bounds (this 1.12.1 client). `.text` per CLAUDE.md; the
// image range bounds candidate string pointers so the suffix check never
// dereferences unmapped memory. Getter strings live in `.data` (e.g.
// Spell.dbc at 0x00859E30), comfortably inside the image.
constexpr uintptr_t kTextStart = 0x00401000;
constexpr uintptr_t kTextEnd = 0x007FEDAC;
constexpr uintptr_t kImageStart = 0x00400000;
constexpr uintptr_t kImageEnd = 0x00D26000;
// True if `s` (bounded, never reading past the image) names a ".dbc" file.
bool LooksLikeDbcString(const uint8_t *s) {
const uint8_t *limit = reinterpret_cast<const uint8_t *>(kImageEnd);
size_t n = 0;
while (n < 260 && s + n < limit && s[n] != '\0')
++n;
if (s + n >= limit || s[n] != '\0' || n < 4) // not terminated, or too short
return false;
return EqualsCI(reinterpret_cast<const char *>(s + n - 4), ".dbc");
}
// Scan .text for the DBC path-getter pattern and add each referenced
// ".dbc" path to the collector. Synchronous, reads only our own image.
void ScanPathGetters(Collector &c) {
const uint8_t *p = reinterpret_cast<const uint8_t *>(kTextStart);
const uint8_t *end = reinterpret_cast<const uint8_t *>(kTextEnd) - 6;
for (; p <= end; ++p) {
if (p[0] != 0xB8 || p[5] != 0xC3) // mov eax, imm32 ; ret
continue;
uint32_t imm;
std::memcpy(&imm, p + 1, sizeof(imm));
if (imm < kImageStart || imm >= kImageEnd)
continue;
const uint8_t *s = reinterpret_cast<const uint8_t *>(imm);
if (LooksLikeDbcString(s))
c.Add(reinterpret_cast<const char *>(s));
}
}
// Read each collected source path from the MPQs and write it under
// `dstRoot`, re-rooted by stripping the leading `prefixLen` chars (the
// "Interface\" / "DBFilesClient\" prefix). Returns the number written.
int WriteFiles(const std::vector<std::string> &files, size_t prefixLen,
const char *dstRoot) {
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
int written = 0;
for (const std::string &src : files) {
void *buf = nullptr;
unsigned int size = 0;
if (FileRead(0, src.c_str(), &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
continue;
// Re-root: "<prefix>Sub\Foo.ext" -> "<dstRoot>\Sub\Foo.ext".
std::string dst = dstRoot;
dst.push_back('\\');
dst.append(src, prefixLen, std::string::npos);
if (WriteFileToDisk(dst, buf, size))
++written;
SMemFree(buf, __FILE__, __LINE__, 0);
}
return written;
}
// Enumerate every MPQ file under `pathPrefix` whose extension matches
// one of `exts`, read each, and write it under `dstRoot`. Returns the
// number of files successfully written.
int ExportTree(const char *pathPrefix, const char *const *exts, int extCount,
const char *dstRoot) {
auto MpqEnumFiles = reinterpret_cast<MpqEnumFiles_t>(Offsets::FUN_MPQ_ENUM_FILES);
Collector collector;
collector.exts = exts;
collector.extCount = extCount;
g_collector = &collector;
MpqEnumFiles(kArchiveSelector, pathPrefix, &CollectCb, nullptr);
g_collector = nullptr;
return WriteFiles(collector.files, std::strlen(pathPrefix), dstRoot);
}
// Export the DBC tables. Unions two sources, deduped case-insensitively:
// 1. the MPQ (listfile) under DBFilesClient\ (catches files that ship
// in the archives without a loader, e.g. wowerror_strings.dbc), and
// 2. the .text path-getter scan (the authoritative set the client
// actually loads — catches the ~18 DBCs absent from the listfile).
// Every DBC path is "DBFilesClient\Name.dbc", so re-rooting strips that
// 14-char prefix and writes under `dstRoot`.
int ExportDBC(const char *dstRoot) {
auto MpqEnumFiles = reinterpret_cast<MpqEnumFiles_t>(Offsets::FUN_MPQ_ENUM_FILES);
Collector collector;
collector.exts = kDbcExts;
collector.extCount = 1;
g_collector = &collector;
MpqEnumFiles(kArchiveSelector, "DBFilesClient\\", &CollectCb, nullptr);
g_collector = nullptr;
ScanPathGetters(collector);
return WriteFiles(collector.files, std::strlen("DBFilesClient\\"), dstRoot);
}
// Console-command handler ABI: edx = the argument text after the
// command name ("" when bare). Mirrors 4.3.4's `art`/`code` dispatch.
bool FirstTokenEquals(const char *s, const char *token) {
size_t i = 0;
for (; token[i] != '\0'; ++i) {
char a = s[i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a + ('a' - 'A'));
if (a != token[i])
return false;
}
const char end = s[i];
return end == '\0' || end == ' ' || end == '\t';
}
int __fastcall Console_ExportInterfaceFiles(void * /*unused*/, const char *args) {
const char *p = (args != nullptr) ? args : "";
while (*p == ' ' || *p == '\t')
++p;
Mode mode;
if (FirstTokenEquals(p, "art"))
mode = MODE_ART;
else if (FirstTokenEquals(p, "code"))
mode = MODE_CODE;
else {
Game::Console::Write("Usage: ExportInterfaceFiles art|code");
return 1;
}
const int written = ExportTree(
"Interface\\",
(mode == MODE_ART) ? kArtExts : kCodeExts,
(mode == MODE_ART) ? 2 : 4,
(mode == MODE_ART) ? "BlizzardInterfaceArt" : "BlizzardInterfaceCode");
char msg[160];
snprintf(msg, sizeof(msg), "ExportInterfaceFiles: wrote %d %s file(s) to %s\\",
written, (mode == MODE_ART) ? "art" : "code",
(mode == MODE_ART) ? "BlizzardInterfaceArt" : "BlizzardInterfaceCode");
Game::Console::Write(msg);
return 1;
}
// `ExportDBCFiles` — ClassicAPI-original companion to ExportInterfaceFiles.
// Dumps every `.dbc` table the client loads to `DBFilesClient\` under the
// working directory. No subcommand — there's only one thing to export.
// Unions the MPQ (listfile) with a `.text` path-getter scan so it gets
// the complete set the client loads, including DBCs the listfile omits.
int __fastcall Console_ExportDBCFiles(void * /*unused*/, const char * /*args*/) {
const int written = ExportDBC("DBFilesClient");
char msg[96];
snprintf(msg, sizeof(msg),
"ExportDBCFiles: wrote %d .dbc file(s) to DBFilesClient\\", written);
Game::Console::Write(msg);
return 1;
}
// Register the console command. A console command is process-global
// (not Lua-state-bound) and persists for the whole session, so the
// single registration at glue boot — the earliest hook, firing at
// launch before any console can be opened — covers both the login
// console and in-world. The glue hook re-fires on each world→glue
// return (logout), but the engine's registrar dedups by name (and we
// pass the same string literal, so its pointer-equality fast path
// catches the repeat), making a second call a no-op. The command name
// and help string are stored by pointer, so they must be static —
// string literals satisfy that for the DLL's lifetime.
void EnsureRegistered() {
Game::Console::RegisterCommand(
"ExportInterfaceFiles", &Console_ExportInterfaceFiles,
Game::Console::CATEGORY_DEBUG,
"Extracts Blizzard's UI files from the MPQs to disk. "
"Usage: ExportInterfaceFiles art|code");
Game::Console::RegisterCommand(
"ExportDBCFiles", &Console_ExportDBCFiles,
Game::Console::CATEGORY_DEBUG,
"Extracts the client's .dbc tables from the MPQs to disk. "
"Usage: ExportDBCFiles");
}
const Game::GlueModuleAutoRegister _autoreg{&EnsureRegistered};
} // namespace
} // namespace Interface::Export
+163 -56
View File
@@ -147,41 +147,54 @@ static int __fastcall Script_IsItemDataCached(void *L) {
return 1;
}
// Pending explicit-request tracking. We deliberately do NOT eagerly
// create cache entries when an addon calls `C_Item.RequestLoadItemData`
// at addon-load / PLAYER_ENTERING_WORLD time — empirically (pfQuest),
// pre-creating the entry blocks the engine's natural prefetch path
// from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The entry stays in a
// permanently-pending state because nothing ever sends the query.
// Pending-request tracking. We deliberately do NOT eagerly create cache
// entries during the startup window — empirically (pfQuest), pre-creating
// the entry at addon-load / PLAYER_ENTERING_WORLD time, before the
// engine's network/opcode dispatch has settled, blocks the engine's
// natural prefetch from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The
// entry stays permanently pending because nothing ever sends the query.
// This applies to BOTH paths into the cache:
// - explicit `C_Item.RequestLoadItemData(ByID)`
// - implicit `WarmCache` (the `GetItemInfo` hook + every `GetItem*`
// getter's cache-miss warmup) — see `WarmCache`.
//
// Instead we hook the engine's item-cache response handler at
// `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that fills cache
// entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`). After the engine
// finishes filling any entries and walking its own callback lists,
// we sweep our pending set and fire `ITEM_DATA_LOAD_RESULT(itemID, 1)`
// for any tracked items that now resolve. The engine's natural
// prefetch (for player-inventory items) takes care of the actual
// `SMSG_ITEM_QUERY_SINGLE` for us.
// Both route through `Track`, which records the item without touching
// the cache while `g_settled` is false. We hook the engine's item-cache
// response handler at `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that
// fills cache entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`); after the
// engine finishes filling entries and walking its own callback lists, we
// sweep our pending set and fire the per-item completion event for any
// tracked item that now resolves. The engine's natural prefetch (for
// player-inventory items) takes care of the actual query for us.
//
// For items the engine *doesn't* naturally prefetch (e.g. quest
// reward IDs the player has never owned), we escalate after
// `kEscalateTicks` by issuing our own `CacheFetch` with a no-op
// For items the engine *doesn't* naturally prefetch (e.g. quest reward
// IDs the player has never owned), `OnWorldTick` escalates once the
// startup gate has settled by issuing our own `CacheFetch` with a no-op
// callback — that creates the entry and queues the query, and the
// response handler hook still fires our event when the data lands.
// `kMaxWaitTicks` is the hard timeout that gives up and fires
// `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
// `kMaxWaitTicks` is the hard timeout that gives up and fires the
// completion event with failure.
struct Pending {
uint32_t itemID;
int ticksWaiting;
bool requestIssued;
bool implicit; // true: fire GET_ITEM_INFO_RECEIVED; false: ITEM_DATA_LOAD_RESULT
bool failed; // set by the escalation callback on a server "no such item" reply
};
constexpr int kMaxPending = 64;
constexpr int kEscalateTicks = 60;
constexpr int kMaxPending = 512;
constexpr int kMaxWaitTicks = 1200;
// World ticks after which we assume the engine's startup
// network/opcode-dispatch state has settled and eager queries are safe.
// Also latched the moment the engine's own response handler fires (a
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
// One-way latch; see the relog note in `WarmCache`.
constexpr int kSettleTicks = 60;
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
static int g_worldTicks = 0;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
@@ -197,27 +210,92 @@ static void RemovePendingAt(int idx) {
g_pending[idx] = g_pending[g_pendingCount];
}
// No-op callback for the escalation path — we just need a non-null
// callback pointer so the engine's `_GetRecord` queues the query.
// The response handler hook will detect the fill and fire the event;
// this callback doesn't need to do anything.
static void __stdcall ItemLoadCallback_NoOp(void * /*userData*/, int /*success*/) {}
// Callback for the escalation path. The engine queues the query when we
// pass a non-null callback to `_GetRecord`; it then invokes this with the
// itemID smuggled through `userData`. On a successful fill the engine has
// already set the entry's loaded flag, so the response-handler sweep
// reports success — we do nothing here. On a server failure (high-bit
// itemID in `SMSG_ITEM_QUERY_SINGLE_RESPONSE`) the engine does NOT set the
// loaded flag (verified in `FUN_0055BDB0`), so the sweep can't detect it;
// we mark the pending entry failed so the sweep / tick fires failure
// immediately instead of waiting out `kMaxWaitTicks`.
static void __stdcall ItemLoadCallback_Escalated(void *userData, int success) {
if (success)
return;
const auto itemID = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(userData));
const int idx = FindPending(itemID);
if (idx >= 0)
g_pending[idx].failed = true;
}
static void RequestAndMaybeNotify(uint32_t itemID) {
// Already loaded — fire success synchronously.
static void FireByTag(uint32_t itemID, bool success, bool implicit) {
if (implicit)
FireGetItemInfoReceived(static_cast<int>(itemID), success);
else
FireItemDataLoadResult(static_cast<int>(itemID), success);
}
// Records an item we owe a completion event for, WITHOUT eagerly
// creating the cache entry. During the startup window (before
// `g_settled`) this is the only safe way to "request" an item: calling
// `CacheFetch` with a callback now would create an orphan entry the
// engine's prefetch then skips, leaving it permanently pending (see the
// block comment above). `OnWorldTick` issues the actual query once
// settled, and the response-handler sweep fires the event — tagged
// implicit (GET_ITEM_INFO_RECEIVED) or explicit (ITEM_DATA_LOAD_RESULT).
// Returns false only when the request was dropped because the pending
// set is full (the caller is still tracking nothing). All other outcomes
// — already-loaded, already-tracking, newly-tracked — return true.
static bool Track(uint32_t itemID, bool implicit) {
if (CacheFetch(itemID, nullptr) != nullptr) {
FireItemDataLoadResult(static_cast<int>(itemID), true);
return;
// Already loaded. Explicit RequestLoadItemData fires its
// completion event synchronously (modern API contract). Implicit
// warmup mirrors the engine's already-loaded shortcut, which does
// not re-invoke the callback — so a cache hit fires nothing.
if (!implicit)
FireItemDataLoadResult(static_cast<int>(itemID), true);
return true;
}
const int idx = FindPending(itemID);
if (idx >= 0) {
// Already tracking. An explicit request supersedes a pending
// implicit one so the explicit caller's ITEM_DATA_LOAD_RESULT is
// honored (the two events are mutually exclusive per fill).
if (!implicit)
g_pending[idx].implicit = false;
return true;
}
// Already tracking — second request just dedups.
if (FindPending(itemID) >= 0)
return;
if (g_pendingCount >= kMaxPending)
return;
return false;
g_pending[g_pendingCount].itemID = itemID;
g_pending[g_pendingCount].ticksWaiting = 0;
g_pending[g_pendingCount].requestIssued = false;
g_pending[g_pendingCount].implicit = implicit;
g_pending[g_pendingCount].failed = false;
++g_pendingCount;
return true;
}
static bool RequestAndMaybeNotify(uint32_t itemID) { return Track(itemID, /*implicit=*/false); }
// Fires + removes a pending entry if it has resolved either way: loaded
// (engine set the flag) → success, or `failed` (escalation callback saw a
// server rejection) → failure. Returns true if it fired and removed the
// entry at `i`, so callers must NOT advance their index when it does.
static bool TryCompletePending(int i) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
if (CacheFetch(itemID, nullptr) != nullptr) {
RemovePendingAt(i);
FireByTag(itemID, true, implicit);
return true;
}
if (g_pending[i].failed) {
RemovePendingAt(i);
FireByTag(itemID, false, implicit);
return true;
}
return false;
}
// Response handler hook. After the engine fills cache entries from
@@ -237,16 +315,17 @@ static void __fastcall ItemStatsCacheResponse_h(void *cache, void * /*edx_unused
void *packetReader, int flag) {
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
reinterpret_cast<Trampoline_t>(ItemStatsCacheResponse_o)(cache, nullptr, packetReader, flag);
// The engine just processed an item-query response, which proves its
// network/opcode dispatch is live — eager queries are safe from here
// on. Latch the startup gate so `WarmCache` stops deferring.
g_settled = true;
// Sweep pending — the engine just finished filling any matching
// entries. Peek (no callback) returns non-null iff the entry's
// loaded flag is set.
// entries (and, on the failure branch, invoked our escalation
// callback which marked any rejected items `failed`). Fire whatever
// resolved this response, success or failure.
for (int i = 0; i < g_pendingCount;) {
const uint32_t itemID = g_pending[i].itemID;
if (CacheFetch(itemID, nullptr) != nullptr) {
RemovePendingAt(i);
FireItemDataLoadResult(static_cast<int>(itemID), true);
if (TryCompletePending(i))
continue;
}
++i;
}
}
@@ -258,23 +337,34 @@ static const Game::HookAutoRegister _hookCacheResponse{
// Escalation + timeout. The hook covers the happy path (engine fills
// cache → we fire). This tick is only responsible for:
// 1. Issuing a query for items the engine doesn't naturally prefetch
// (e.g. quest rewards) — wait `kEscalateTicks` so we don't race
// the engine's startup-time state machine, then call CacheFetch
// with a no-op callback. The engine sends the SMSG, the response
// handler hook fires the event when the response lands.
// 2. Giving up after `kMaxWaitTicks` for items that never resolve
// (bad itemID, server doesn't respond, etc.) — fire failure.
// 1. Latching the startup gate via a tick-count fallback, in case no
// engine response arrives early enough to latch it first.
// 2. Issuing a query for items the engine doesn't naturally prefetch
// (e.g. quest rewards) — but only once `g_settled`, so we don't
// race the engine's startup-time state machine. The engine sends
// the SMSG, the response handler hook fires the event when the
// response lands.
// 3. Firing failure immediately for items the server rejected (the
// escalation callback marked them `failed`), and as a last resort
// giving up after `kMaxWaitTicks` for items that never resolve at
// all (lost query, server silent) — fire failure.
static void OnWorldTick() {
if (!g_settled && ++g_worldTicks >= kSettleTicks)
g_settled = true;
for (int i = 0; i < g_pendingCount;) {
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
if (!g_pending[i].requestIssued && g_settled) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
g_pending[i].requestIssued = true;
}
// A server rejection (marked by the escalation callback) resolves
// the item as failure without waiting out the timeout.
if (TryCompletePending(i))
continue;
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
RemovePendingAt(i);
FireItemDataLoadResult(static_cast<int>(itemID), false);
FireByTag(itemID, false, implicit);
continue;
}
++g_pending[i].ticksWaiting;
@@ -291,7 +381,24 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// `ITEM_DATA_LOAD_RESULT`), matching modern semantics for ambient
// cache fills.
void WarmCache(uint32_t itemID) {
CacheFetch(itemID, &ItemLoadCallback_Implicit);
// Post-settle (steady state): query immediately for lowest latency.
// The engine invokes our implicit callback when the data lands.
if (g_settled) {
CacheFetch(itemID, &ItemLoadCallback_Implicit);
return;
}
// Startup window: do NOT eagerly create the cache entry — that
// orphans it (the engine's prefetch skips already-existing entries,
// and our SMSG goes into a hole while dispatch isn't ready), leaving
// the item permanently nil for the session. This is the exact race
// that broke pfQuest/Questie scanning at PLAYER_ENTERING_WORLD. Defer
// through the pending set instead; OnWorldTick issues the query once
// settled and the response-handler sweep fires GET_ITEM_INFO_RECEIVED.
//
// NOTE: g_settled is a one-way latch per process. The race this
// guards is the initial-login cold-WDB window. On relog the WDB is
// already warm so cold-fetch pressure is minimal; we don't re-arm it.
Track(itemID, /*implicit=*/true);
}
// Hook for `Script_GetItemInfo`. Pre-warms the cache from arg 1, then
@@ -314,8 +421,8 @@ static int __fastcall Script_RequestLoadItemDataByID(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 1);
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
@@ -329,8 +436,8 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 1);
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
return 1;
}
+139
View File
@@ -0,0 +1,139 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.GetEnchantInfo(enchantID)` -> table | nil
//
// Resolves an item-enchantment ID — the kind `C_Item.GetWeaponEnchantInfo`
// returns for a weapon's temporary enchant (poisons, oils, sharpening
// stones, shaman imbues, …), and the same IDs item permanent enchants
// use — into its `SpellItemEnchantment.dbc` record and returns a table:
//
// {
// enchantID = <id>,
// name = "<localized display name>", -- "Crusader", "+8 Spirit"
// effects = { -- 1..3 active effect slots
// { type = <ITEM_ENCHANTMENT_TYPE>, amount = <int>, arg = <int> },
// ...
// },
// spellID = <int>, -- present only for spell-type enchants (proc/
// -- equip/use): the EffectArg of the first such
// -- effect, e.g. Crusader -> 20007 "Holy Strength".
// }
//
// `effects[i].type` is the standard ITEM_ENCHANTMENT_TYPE:
// 1 = combat-proc spell (arg = spellID)
// 2 = weapon damage (amount = +dmg)
// 3 = equip spell / aura (arg = spellID)
// 4 = resistance / armor (amount = +value)
// 5 = stat (arg = stat index, amount = value)
// 6 = totem, 7 = use spell
// For spell types (1/3/7) `arg` is a spellID you can feed straight into
// `C_Spell.GetSpellInfo` / `GetSpellDescription`; `spellID` is surfaced
// at top level as a convenience for that common case.
//
// Returns nil for a non-numeric / non-positive id, an out-of-range id,
// or a record with no name. Lives in C_Item (not C_Spell) because the
// id originates from `C_Item.GetWeaponEnchantInfo` and the concept is an
// item enchantment — `SpellItemEnchantment` is just the DBC's internal
// name (enchants are *implemented* via spell effects).
//
// Not derivable: the source item. The record holds no back-reference to
// the item that applied the enchant, and vanilla has no client-side
// reverse index (enchantID -> item).
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
namespace Item::EnchantInfo {
namespace {
constexpr int kNumEffects = 3;
constexpr int kNumNameLocales = 8; // Name[8]; field after is the locale mask
// Spell-type enchants whose EffectArg is a spellID (proc / equip / use).
bool IsSpellType(int type) { return type == 1 || type == 3 || type == 7; }
int __fastcall Script_C_Item_GetEnchantInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int enchantID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (enchantID <= 0)
return 0;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT,
static_cast<uint32_t>(enchantID));
if (rec == nullptr)
return 0; // OOR id or unpopulated slot -> nil
// Localized display name (8-slot locale array at +0x34). Clamp the
// locale index to the array bounds — index 8 would read the locale
// mask column that follows. enUS (0) fallback for empty slots.
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLITEMENCHANT_NAME);
int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (locale < 0 || locale >= kNumNameLocales)
locale = 0;
const char *name = names[locale];
if (name == nullptr || name[0] == '\0')
name = names[0]; // enUS fallback
if (name == nullptr || name[0] == '\0')
return 0; // unnamed record -> nil
auto type = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_ARG);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "enchantID", enchantID);
Game::Lua::SetFieldString(L, "name", name);
// effects = { {type,amount,arg}, ... } — only non-empty slots.
Game::Lua::PushString(L, "effects");
Game::Lua::NewTable(L);
int n = 0;
int spellID = 0;
for (int i = 0; i < kNumEffects; ++i) {
if (type[i] == 0)
continue;
if (spellID == 0 && IsSpellType(type[i]) && arg[i] != 0)
spellID = arg[i];
++n;
Game::Lua::PushNumber(L, n); // 1-based array index
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "type", type[i]);
Game::Lua::SetFieldNumber(L, "amount", amount[i]);
Game::Lua::SetFieldNumber(L, "arg", arg[i]);
Game::Lua::SetTable(L, -3); // effects[n] = entry
}
Game::Lua::SetTable(L, -3); // result.effects = effects
if (spellID != 0)
Game::Lua::SetFieldNumber(L, "spellID", spellID);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetEnchantInfo",
&Script_C_Item_GetEnchantInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::EnchantInfo
+90
View File
@@ -0,0 +1,90 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.UseAtUnit(itemInfo, unit)` — use the named item at the given
// unit's feet. For items whose on-use spell is ground-target (Iron
// Grenade, demolition charges, etc.), bypasses the manual click on the
// AoE reticle. `UseAtUnit(itemInfo, "target")` drops the reticle at
// the target's position.
//
// Unit-position analog of `C_Item.UseAtCursor` — same bag-walk +
// `FUN_ITEM_USE` dispatch, but the placement is committed at the
// unit's world position (via `Spell::AtCursor::CommitAtCoords`)
// instead of the cursor raycast.
//
// Returns `true` when the placement landed at the unit; `false` for
// non-ground-target items (the item still fires, no implicit target),
// unparseable input, item-not-in-bags, or an unresolvable unit.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "spell/AtCursor.h"
#include "unit/Position.h"
#include <cstdint>
namespace Item::UseAtUnit {
namespace {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
int __fastcall Script_C_Item_UseAtUnit(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit position first — fail fast before firing the
// item if the unit is absent. (FindByArgInBags below stomps the
// Lua stack, so read the token argument before that runs.)
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushBool(L, false);
return 1;
}
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t zeroTarget = 0;
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &zeroTarget, 0);
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "UseAtUnit",
&Script_C_Item_UseAtUnit);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::UseAtUnit
+2 -8
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -74,14 +75,7 @@ uint16_t LocalPlayerArea() {
static_cast<uintptr_t>(Offsets::VAR_PLAYER_AREA_ID)));
}
uint64_t LocalPlayerGUID() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t LocalPlayerGUID() { return Unit::Identity::PlayerGuid(); }
// Pushes `area` as a number, or nil if `area == 0`. Vanilla zone 0
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
#pragma once
#include <cstdint>
// Minimal reader for the engine's `CDataStore` packet buffer, shared by the
// packet co-hooks (`Aura::Source`'s SpellGo, `Spell::Cast`'s SpellStart).
//
// `CDataStore` is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
// so a vtable pointer sits at offset 0 and the data members start at +0x04.
// Omitting `vtable` shifts every field by 4 and makes `m_buffer` read the
// vptr — a garbage read address (this caused an early ACCESS_VIOLATION in
// the SpellGo hook before the slot was added).
//
// Incoming SMSG packets are a single contiguous buffer (`m_base == 0`,
// `m_alloc == size`), so the simple `m_buffer - m_base + m_read` read with
// an `m_size` bound is sufficient — the same critical guard as the engine's
// own `CDataStore::Get`. Callers should save `m_read` before reading and
// restore it before handing the packet to the original handler.
namespace Net {
struct CDataStore {
void *vtable; // +0x00
uint8_t *m_buffer; // +0x04
uint32_t m_base; // +0x08
uint32_t m_alloc; // +0x0C
uint32_t m_size; // +0x10
uint32_t m_read; // +0x14
};
// Reads a fixed-width field and advances the cursor. On overrun it poisons
// `m_read` (so subsequent reads short-circuit) and returns a zero value.
template <typename T>
T Read(CDataStore *p) {
T val{};
const uint32_t end = p->m_read + sizeof(T);
if (end > p->m_size) {
p->m_read = p->m_size + 1;
return val;
}
val = *reinterpret_cast<const T *>(p->m_buffer - p->m_base + p->m_read);
p->m_read += sizeof(T);
return val;
}
// Reads a WoW packed GUID: a 1-byte mask followed by one byte per set bit,
// each contributing the corresponding byte of the 64-bit value.
inline uint64_t ReadPackedGuid(CDataStore *p) {
uint64_t guid = 0;
const uint8_t mask = Read<uint8_t>(p);
for (int i = 0; i < 8; ++i) {
if (mask & (1u << i))
guid |= static_cast<uint64_t>(Read<uint8_t>(p)) << (i * 8);
}
return guid;
}
} // namespace Net
+89
View File
@@ -41,6 +41,7 @@
#include "guid/Guid.h"
#include <cstdint>
#include <cstring>
namespace Player::Info {
@@ -182,6 +183,92 @@ int PushFromNameCacheEntry(void *L, const char *name,
return 7;
}
// `UnitNameFromGUID(guid)` → `(name, realm)`. Modern WoW backport;
// vanilla 1.12 doesn't expose any GUID → name accessor at the Lua
// surface, so addons that need to resolve a chat link or combat-log
// GUID can't get a name without round-tripping through a unit token.
//
// Resolution order (matches vanilla's own `Script_UnitName` at
// `0x00517020`):
// 1. Resolve the GUID through the object manager. Any currently-
// synced unit — player you can see, NPC in interact range,
// target / mouseover / party / raid member — gets a `CGObject *`
// back. Call the engine's canonical polymorphic name getter
// `FUN_OBJECT_GET_NAME` on it; this routes through the player
// NameCache for players AND the creature cache for NPCs, so it
// works for both with a single call.
// 2. If the unit isn't currently synced (ex-target who logged off,
// player encountered earlier in chat but no longer visible),
// fall back to the persistent NameCache (when enabled).
//
// `realm` is always `""` in vanilla — the engine doesn't populate
// per-player realm names and 1.12 has no cross-realm interaction.
// We push the empty string rather than nil so callers don't have to
// special-case the second return; modern WoW returns nil, but
// `realm == ""` is just as easy to gate on.
//
// Returns nothing on:
// - Missing or non-string arg (raises an error rather than return).
// - Unparseable GUID string.
// - Zero / NULL GUID.
// - GUID not resolvable in the object manager AND not in persistent
// NameCache (i.e., we've never seen this unit).
//
// Doesn't trigger a network query on miss — passive read, same as
// `GetPlayerInfoByGUID`. Use `C_PlayerCache.RememberPlayer` or let
// the engine populate via normal interaction.
int __fastcall Script_UnitNameFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint32_t hi, lo;
if (!ParseGUID(guidStr, hi, lo))
return 0;
if (hi == 0 && lo == 0)
return 0;
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
// objects (gameobjects etc.), so we just gate on the sentinel
// rather than pre-filtering by typemask.
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
auto objectPtr = reinterpret_cast<ObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = objectPtr(Offsets::TYPEMASK_OBJECT, nullptr, lo, hi, 0);
if (obj != nullptr) {
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(obj, nullptr);
if (name != nullptr && *name != '\0' &&
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
std::strcmp(name, "Unknown Being") != 0) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, "");
return 2;
}
}
// Object-manager miss / sentinel — fall back to the persistent
// NameCache (when enabled). Covers ex-units no longer in the
// engine's sync window.
const std::string *cachedName = nullptr;
const NameCache::Entry *cached = NameCache::Lookup(
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
return 0;
Game::Lua::PushString(L, cachedName->c_str());
Game::Lua::PushString(L, "");
return 2;
}
// `C_PlayerCache.GetPlayerInfoByName(name)` — name-keyed lookup over
// the persistent NameCache. Returns `(localizedClass, englishClass,
// localizedRace, englishRace, sex, name, realm, guid)` or nothing if
@@ -212,6 +299,8 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
&Script_GetPlayerInfoByGUID);
Game::Lua::RegisterGlobalFunction("UnitNameFromGUID",
&Script_UnitNameFromGUID);
Game::Lua::RegisterTableFunction("C_PlayerCache", "GetPlayerInfoByName",
&Script_C_PlayerCache_GetPlayerInfoByName);
}
+12 -1
View File
@@ -111,10 +111,21 @@ void OnWorldTick() {
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
ctrl + Offsets::OFF_UI_INPUT_FLAGS);
const bool moving = (flags & Offsets::INPUT_FLAGS_MOVING_ANY) != 0;
const bool freeLookHeld = (flags & Offsets::INPUT_FLAG_FREE_LOOK) != 0;
const bool mouselookHeld = (flags & Offsets::INPUT_FLAG_MOUSELOOK) != 0;
// MOVING — translational input. The WASD / autorun keys set the
// INPUT_FLAGS_MOVING_ANY bits directly. The "hold both mouse
// buttons to run forward" gesture does NOT: the engine drives that
// forward move at the movement layer (FUN_005103e0 from the button
// handler) and actually *clears* the autorun bit on the both-button
// press, so no MOVING_ANY bit is ever set. Detect it from the
// bit combo instead — both mouselook (RMB) and free-look (LMB)
// held means the engine is mouse-steering the character forward.
const bool mouseSteerMoving = mouselookHeld && freeLookHeld;
const bool moving =
(flags & Offsets::INPUT_FLAGS_MOVING_ANY) != 0 || mouseSteerMoving;
// TURNING — latched bit-AND-rotation signal:
// STARTED fires when mouselook bit is held AND the body yaw
// changes (the first actual drag after pressing RMB).
+2 -10
View File
@@ -37,7 +37,7 @@
// player object is ready, and stay stable across zone transitions.
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -46,15 +46,7 @@ namespace Player::LoginStatus {
namespace {
int __fastcall Script_IsLoggedIn(void *L) {
auto *player = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_LOCAL_PLAYER_PTR);
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
Game::Lua::PushBool(L, guid != 0);
Game::Lua::PushBool(L, Unit::Identity::PlayerGuid() != 0);
return 1;
}
+2 -8
View File
@@ -62,6 +62,7 @@
#include "Offsets.h"
#include "guid/Guid.h"
#include "settings/Account.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
@@ -467,14 +468,7 @@ UnitBytes0 ReadBytes0(void *unit) {
// on the local CGPlayer_C type, or it is but returns 0x1 to mean
// "no remote name; ask the local source." Either way we don't need
// to cache ourselves — we have the data via every other path.
uint64_t LocalPlayerGUID() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t LocalPlayerGUID() { return Unit::Identity::PlayerGuid(); }
// Pointer sanity check before dereferencing. The CGObject vftable
// GetName slot has at least one known case where it returns 0x1
+111
View File
@@ -0,0 +1,111 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetPhysicalScreenSize()` → (widthPixels, heightPixels) — the
// display's physical resolution in pixels. Modern WoW added this as a
// native in 7.0 (Legion); FrameXML's `PixelUtil` hangs its entire
// pixel↔UI-unit conversion off it (`768.0 / physicalHeight`), which is
// what lets addons snap frames to whole pixels.
//
// Vanilla 1.12 has no such native and no pre-parsed pixel-dimension
// global — the engine only ever holds the current mode as the
// `gxResolution` CVar string ("WIDTHxHEIGHT"). `Script_GetScreenWidth`
// / `GetScreenHeight` return UI-space units (derived from the UIParent
// aspect ratio at `[UIParent + 0x7c]`), not pixels, so they can't
// serve. `SetScreenResolution` (`FUN_0048bfd0`) and the display-init
// path both sscanf the `gxResolution` value on demand rather than
// caching width/height anywhere — so the CVar string is the
// authoritative current-resolution source, and we parse it the same
// way the engine does.
//
// Returns 1024x768 (→ factor 1.0, i.e. 1 pixel == 1 UI unit, making
// PixelUtil a no-op pass-through) when the CVar is missing or
// unparseable. Caveat: in windowed mode `gxResolution` reflects the
// configured render resolution, which may differ from the OS window's
// client size — vanilla exposes no separate window-pixel query.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Screen::Info {
namespace {
using FindCVar_t = const uint8_t *(__fastcall *)(const char *name);
// Parses "WIDTHxHEIGHT" (e.g. "1920x1080"). Tolerant of the separator
// (any run of non-digits between the two numbers) so an uppercase 'X'
// or stray spaces don't break it. Returns false unless both numbers
// are present and positive.
bool ParseResolution(const char *s, int *outW, int *outH) {
if (s == nullptr)
return false;
const char *p = s;
int w = 0;
bool gotW = false;
while (*p >= '0' && *p <= '9') {
w = w * 10 + (*p - '0');
++p;
gotW = true;
}
if (!gotW)
return false;
while (*p != '\0' && (*p < '0' || *p > '9')) // skip the 'x' separator
++p;
int h = 0;
bool gotH = false;
while (*p >= '0' && *p <= '9') {
h = h * 10 + (*p - '0');
++p;
gotH = true;
}
if (!gotH || w <= 0 || h <= 0)
return false;
*outW = w;
*outH = h;
return true;
}
int __fastcall Script_GetPhysicalScreenSize(void *L) {
int width = 1024;
int height = 768; // fallback → 768/768 = factor 1.0 (no-op snapping)
auto findCVar = reinterpret_cast<FindCVar_t>(Offsets::FUN_FIND_CVAR);
const uint8_t *cvar = findCVar("gxResolution");
if (cvar != nullptr) {
const char *value = *reinterpret_cast<const char *const *>(
cvar + Offsets::OFF_CVAR_VALUE_STR);
ParseResolution(value, &width, &height);
}
Game::Lua::PushNumber(L, width);
Game::Lua::PushNumber(L, height);
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPhysicalScreenSize",
&Script_GetPhysicalScreenSize);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Screen::Info
+51 -23
View File
@@ -70,9 +70,17 @@ void CancelPlacement() {
fn();
}
} // namespace
void Commit(const float coords[3]) {
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
commit(coords);
}
bool Resolve() {
// Shared placement-readiness gate for both the cursor and explicit-
// coords commit paths. Decision tree (preserved from the original
// Resolve): no placement → false (no cancel); non-ground placement →
// cancel + false; not in world → false; otherwise ready → true.
bool PlacementReady() {
const uint32_t state = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_SPELL_PLACEMENT_STATE);
if (state == 0)
@@ -81,12 +89,25 @@ bool Resolve() {
if ((state & Offsets::SPELL_PLACEMENT_GROUND_MASK) == 0) {
// Spell wants a unit / item / corpse / etc. — not ground.
// Cancel so the cursor recovers; caller learns via the false
// return that no cursor-placement happened.
// return that no placement happened.
CancelPlacement();
return false;
}
if (!InWorld())
return InWorld();
}
} // namespace
bool CommitAtCoords(const float coords[3]) {
if (!PlacementReady())
return false;
Commit(coords);
return true;
}
bool Resolve() {
if (!PlacementReady())
return false;
void *clickInfo = *reinterpret_cast<void *const *>(
@@ -114,9 +135,7 @@ bool Resolve() {
const float *coords = reinterpret_cast<const float *>(
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_COORDS);
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
commit(coords);
Commit(coords);
return true;
}
@@ -126,16 +145,11 @@ using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (spellID <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
} // namespace
bool DispatchSpellCast(int spellID) {
if (spellID <= 0)
return false;
// Hand the spellID off as a numeric string — our `Spell::CastByID`
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
@@ -148,17 +162,31 @@ int __fastcall Script_C_Spell_CastAtCursor(void *L) {
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
if (slot < 0) {
// Spell not in the player's spellbook — can't cast it from
// here either way. Match `CastSpellByName`'s silent failure.
Game::Lua::PushBool(L, false);
return 1;
}
if (slot < 0)
return false; // not in the player's spellbook
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
dispatch(static_cast<unsigned>(slot), bookType,
0, 0.0f); // no implicit target unit; engine handles placement
return true;
}
namespace {
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
// Spell not in the player's spellbook → match `CastSpellByName`'s
// silent failure.
if (!DispatchSpellCast(spellID)) {
Game::Lua::PushBool(L, false);
return 1;
}
const bool placed = Resolve();
Game::Lua::PushBool(L, placed);
+18
View File
@@ -30,4 +30,22 @@ namespace Spell::AtCursor {
// click-info struct pointer.
bool Resolve();
// Commits an in-flight ground-target placement at explicit world
// coords (x, y, z) instead of the cursor raycast. Same placement
// guards as `Resolve` (no placement active → false; non-ground
// placement → cancel + false; not in world → false), but skips the
// cursor entirely. Used by `C_Spell.CastAtUnit` / `C_Item.UseAtUnit`
// to drop the reticle at a unit's feet. Returns true when the
// placement was committed.
bool CommitAtCoords(const float coords[3]);
// Resolves a numeric `spellID` to a spellbook slot and dispatches the
// cast through the engine — the shared front half of
// `C_Spell.CastAtCursor` / `CastAtUnit`. For a ground-target spell
// the engine enters placement mode (commit follows via `Resolve` /
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
// true if the cast was dispatched (spell is in the player's
// spellbook), false otherwise.
bool DispatchSpellCast(int spellID);
} // namespace Spell::AtCursor
+105
View File
@@ -0,0 +1,105 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.CastAtUnit(spellID, unit)` — cast a ground-target spell at
// the given unit's feet, bypassing the manual click on the AoE
// reticle. `CastAtUnit(spellID, "player")` drops it at the player's
// position, `"target"` at the target's, etc.
//
// Same two-step shape as `C_Spell.CastAtCursor`, but the placement is
// committed at the unit's world position instead of the cursor
// raycast:
//
// 1. `Spell::AtCursor::DispatchSpellCast(spellID)` initiates the
// cast; the engine enters placement mode for a ground-target
// spell.
// 2. Read the unit's world position (vtable `GetPosition`) and
// `Spell::AtCursor::CommitAtCoords()` commits the placement
// there.
//
// Returns `true` when the placement was committed at the unit; `false`
// when the spell isn't ground-target (placement cancelled), the spell
// isn't in the spellbook, or the unit has no resolvable position. The
// cast itself still proceeds for non-ground spells, just with no
// implicit target — matching `CastAtCursor`'s semantics.
#include "Game.h"
#include "spell/AtCursor.h"
#include "spell/MacroPrimarySpell.h"
#include "unit/Position.h"
namespace Spell::AtUnit {
namespace {
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
// bad/absent unit doesn't leave the engine sitting in placement
// mode. (A genuinely unrecognized token string raises the engine's
// standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.)
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushBool(L, false);
return 1;
}
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
Game::Lua::PushBool(L, false);
return 1;
}
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "CastAtUnit",
&Script_C_Spell_CastAtUnit);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Macro-parser pattern: tag macros calling
// `C_Spell.CastAtUnit(<spellID>, ...)` with the spellID so action-bar
// UIs highlight the slot. The first arg is the numeric spellID, so the
// extractor just reads the leading numeric literal — same shape as
// `CastAtCursor`'s.
int ExtractCastAtUnitArg(const char *p, const char *end) {
while (p < end && (*p == ' ' || *p == '\t'))
++p;
int value = 0;
const char *digitStart = p;
while (p < end && *p >= '0' && *p <= '9') {
if (value > 100000000) // sanity cap before overflow
return 0;
value = value * 10 + (*p - '0');
++p;
}
return (p > digitStart && value > 0) ? value : 0;
}
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
"C_Spell.CastAtUnit(", &ExtractCastAtUnitArg};
} // namespace
} // namespace Spell::AtUnit
+432
View File
@@ -0,0 +1,432 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Cast / channel info — `UnitCastingInfo`, `CastingInfo`,
// `UnitChannelInfo`, `ChannelInfo`.
//
// Vanilla 1.12 stores NO cast start/end times anywhere readable (unlike
// 3.3.5+, which keeps them per-unit on the CGUnit). The cast bar is
// Lua-driven off `SPELLCAST_START(duration)` + `GetTime()`. So we
// self-track the LOCAL PLAYER's cast/channel on `WorldTick`:
// - cast: `VAR_CURRENT_CAST_SPELL` (the player cast-bar spellID
// global). On a 0->nonzero transition we stamp startMs = now and
// endMs = now + base cast time (SpellCastTimes.dbc). Instant casts
// (cast time 0) and channels are filtered out here — a channel has
// no cast time and surfaces through the channel field instead.
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228). endMs = now + base channel duration
// (SpellDuration.dbc).
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
//
// Other units have no engine-stored cast record, so we capture their casts
// from the `SMSG_SPELL_START` packet (the only place the client sees
// another unit's cast + a server-authoritative cast time) into a per-caster
// cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
//
// Modern-signature fields vanilla can't fill are structurally-correct
// placeholders: castID / castBarID = nil, notInterruptible = false,
// isTradeskill = false (no readable flag in 1.12), delayTimeMs = 0.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Spell::Cast {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using TickMs_t = uint32_t(__fastcall *)();
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
// Spell.dbc record field offsets (mirrors Spell::Info's locals).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
// cooking with a cast, etc.). The version-stable `isTradeskill` source:
// 3.3.5's Script_UnitCastingInfo reads bit 0x20 of Attributes (at +0x10
// there, +0x18 here — the bit value is the same across builds).
constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
struct TrackedSpell {
int spellID; // 0 = not casting / channeling
int startMs;
int endMs;
};
TrackedSpell g_cast{0, 0, 0};
TrackedSpell g_channel{0, 0, 0};
int g_lastCast = 0;
int g_lastChannel = 0;
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
}
// Effective cast time (ms) for the local player, 0 if instant. Uses the
// engine's own cast-time helper (base + level scaling + cast-time SpellMod
// op 10 + the caster's haste/cast-speed multiplier), so our end time
// matches the engine's cast bar exactly — including talents like a Mage's
// faster casts. Unit arg 0 = local player; flag 0 clamps negatives to 0.
int CastTimeMs(int spellID) {
return static_cast<int>(
reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME)(spellID, 0, 0));
}
// Effective channel duration (ms) for the local player, 0 if none. Uses
// the engine's own duration helper (SpellDuration base + level scaling +
// duration SpellMod op 1), mirroring how the cast time is sourced, so the
// channel end time matches the engine. Unit arg 0 = local player.
int ChannelDurationMs(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return 0;
const int ms = reinterpret_cast<GetDuration_t>(Offsets::FUN_GET_SPELL_DURATION)(rec, 0, 0);
return ms > 0 ? ms : 0; // negative = "infinite"; channels are finite
}
// The local player's currently-channeled spellID (broadcast +0x228), or 0.
int PlayerChannelSpell() {
void *p = Resolve("player");
if (p == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(p) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
}
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
}
bool IsTradeskill(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES) & SPELL_ATTR_TRADESPELL) != 0;
}
// Spell icon texture path, or "" if there's no icon record. SpellIcon.dbc
// already stores the full "Interface\Icons\..." path (unlike item icons,
// which are bare filenames) — so it's used verbatim, no prefix.
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
if (iconRec == nullptr)
return "";
const char *path = *reinterpret_cast<const char *const *>(iconRec + OFF_SUBRECORD_VALUE);
return (path != nullptr) ? path : "";
}
// Pushes UnitCastingInfo's 11-tuple from a tracked cast, or nothing (nil)
// if there's no active cast.
int PushCastInfo(void *L, const TrackedSpell &c) {
if (c.spellID == 0)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(c.spellID);
if (rec == nullptr)
return 0;
const char *name = SpellName(rec);
Game::Lua::PushString(L, name); // 1 name
Game::Lua::PushString(L, name); // 2 displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
Game::Lua::PushNumber(L, static_cast<double>(c.startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(c.endMs)); // 5 endTimeMs
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushNil(L); // 7 castID
Game::Lua::PushBool(L, false); // 8 notInterruptible
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
Game::Lua::PushNil(L); // 10 castBarID
Game::Lua::PushNumber(L, 0); // 11 delayTimeMs
return 11;
}
// Pushes UnitChannelInfo's 8-tuple. `haveTimes` is false for remote units
// (we only track the local player's channel start), pushing nil times.
int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes) {
if (spellID == 0)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return 0;
const char *name = SpellName(rec);
Game::Lua::PushString(L, name); // 1 name
Game::Lua::PushString(L, name); // 2 text / displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
if (haveTimes) {
Game::Lua::PushNumber(L, static_cast<double>(startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(endMs)); // 5 endTimeMs
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
}
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushBool(L, false); // 7 notInterruptible
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 8 spellID
return 8;
}
// True if the unit at stack index 1 resolves to the local player.
bool ArgIsPlayer(void *L) {
if (!Game::Lua::IsString(L, 1))
return false;
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return false;
void *u = Resolve(token);
return u != nullptr && u == Resolve("player");
}
// ---- Remote-unit cast tracking (SMSG_SPELL_START) ------------------------
// Vanilla stores no cast record for other units, so we capture their casts
// from SMSG_SPELL_START — the only packet carrying another unit's cast with
// a server-authoritative cast time. Keyed by caster GUID (a unit casts one
// thing at a time). Regular casts back `UnitCastingInfo`; channels add real
// times to `UnitChannelInfo` (validated against the live +0x228 field).
constexpr int OFF_ATTRIBUTES_EX = 0x1C; // Spell.dbc AttributesEx
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
bool IsChannelSpell(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
SPELL_ATTR_EX_CHANNELED) != 0;
}
// Channel duration for a non-player caster — base (skipMod=1), since we
// only have the local player's spell-mod tables.
int RemoteChannelDurationMs(const uint8_t *rec) {
const int ms = reinterpret_cast<GetDuration_t>(
Offsets::FUN_GET_SPELL_DURATION)(rec, 0, /*skipMod*/ 1);
return ms > 0 ? ms : 0;
}
struct RemoteCast {
uint64_t casterGuid;
int spellID;
int startMs;
int endMs;
bool isChannel;
bool used;
};
constexpr int kRemoteCastSlots = 64;
RemoteCast g_remoteCasts[kRemoteCastSlots];
int g_remoteCursor = 0;
void StoreRemoteCast(uint64_t caster, int spellID, int startMs, int endMs,
bool isChannel) {
const RemoteCast entry{caster, spellID, startMs, endMs, isChannel, true};
// One active cast per unit — replace any existing entry for this caster.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == caster) {
e = entry;
return;
}
}
const int now = NowMs();
for (auto &e : g_remoteCasts) {
if (!e.used || now >= e.endMs) {
e = entry;
return;
}
}
g_remoteCasts[g_remoteCursor] = entry;
g_remoteCursor = (g_remoteCursor + 1) % kRemoteCastSlots;
}
const RemoteCast *FindRemoteCast(uint64_t caster) {
if (caster == 0)
return nullptr;
for (const auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == caster)
return &e;
}
return nullptr;
}
void RecordRemoteStart(uint64_t caster, int spellID, uint32_t castTime) {
if (caster == 0 || spellID == 0)
return;
// The local player uses the self-tracked g_cast / g_channel path.
if (caster == Unit::Identity::PlayerGuid())
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
const int now = NowMs();
const bool channel = IsChannelSpell(rec);
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
// other packet this handler processes is a cheap early-out. Packet layout
// mirrors nampower's SpellStartHandlerHook.
using SpellStartHandler_t = int(__fastcall *)(uint32_t unk, uint32_t opCode,
uint32_t unk2,
Net::CDataStore *packet);
SpellStartHandler_t g_origSpellStart = nullptr;
int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
uint32_t unk2, Net::CDataStore *packet) {
if (opCode == 0x131 && packet != nullptr) {
const uint32_t saved = packet->m_read;
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
packet->m_read = saved; // restore before the engine re-parses
RecordRemoteStart(caster, spellID, castTime);
}
return g_origSpellStart(unk, opCode, unk2, packet);
}
const Game::HookAutoRegister _spellStartHook{
Offsets::FUN_SPELL_START_HANDLER,
reinterpret_cast<void *>(&SpellStartHandler_h),
reinterpret_cast<void **>(&g_origSpellStart)};
} // namespace
// Per-frame: detect the player's cast/channel start, stamp times.
void OnWorldTick() {
const int now = NowMs();
const int cast = *reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL);
if (cast != g_lastCast) {
const int dur = (cast != 0) ? CastTimeMs(cast) : 0;
if (cast != 0 && dur > 0) {
g_cast = {cast, now, now + dur};
} else {
g_cast.spellID = 0; // 0, instant, or channel — no cast bar
}
g_lastCast = cast;
}
const int chan = PlayerChannelSpell();
if (chan != g_lastChannel) {
if (chan != 0) {
g_channel = {chan, now, now + ChannelDurationMs(chan)};
} else {
g_channel.spellID = 0;
}
g_lastChannel = chan;
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// `CastingInfo()` — local player's cast, no token lookup.
static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cast); }
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
void *u = (token != nullptr) ? Resolve(token) : nullptr;
if (u == nullptr)
return 0;
if (u == Resolve("player"))
return PushCastInfo(L, g_cast);
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
if (rc != nullptr && !rc->isChannel && NowMs() < rc->endMs)
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs});
return 0;
}
// `ChannelInfo()` — local player's channel, no token lookup.
static int __fastcall Script_ChannelInfo(void *L) {
return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true);
}
// `UnitChannelInfo(unit)` — full timing for the player; spellID/name/
// texture (no times) for other units via the broadcast +0x228 field.
static int __fastcall Script_UnitChannelInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitChannelInfo(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
void *u = (token != nullptr) ? Resolve(token) : nullptr;
if (u == nullptr)
return 0;
if (u == Resolve("player"))
return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true);
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(u) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
// The live +0x228 field is authoritative for "is this unit channeling
// right now"; the SMSG_SPELL_START cache adds real start/end times when
// we observed the channel begin (and still matches the current spell).
const int spellID = *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (spellID == 0)
return 0;
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
if (rc != nullptr && rc->isChannel && rc->spellID == spellID && NowMs() < rc->endMs)
return PushChannelInfo(L, spellID, rc->startMs, rc->endMs, /*haveTimes*/ true);
return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false);
}
static void RegisterLuaFunctions() {
// Registered under C_Spell rather than as globals to avoid clobbering
// the global `UnitCastingInfo` / `UnitChannelInfo` names. Addons that
// ship their own vanilla cast-tracking libraries use the
// `_G.UnitCastingInfo or <fallback>` idiom (e.g. ShaguTweaks'
// libcast.lua scrapes the combat log for remote/enemy casts the 1.12
// engine never exposes). Occupying the global makes them adopt our
// player-only version and drop their superior fallback — so we cede
// the global names and expose the functions here instead.
Game::Lua::RegisterTableFunction("C_Spell", "UnitCastingInfo", &Script_UnitCastingInfo);
Game::Lua::RegisterTableFunction("C_Spell", "CastingInfo", &Script_CastingInfo);
Game::Lua::RegisterTableFunction("C_Spell", "UnitChannelInfo", &Script_UnitChannelInfo);
Game::Lua::RegisterTableFunction("C_Spell", "ChannelInfo", &Script_ChannelInfo);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Cast
+90
View File
@@ -0,0 +1,90 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellMechanicByID(spellID)` -> (mechanicID, localizedName)
//
// Reads the spell-level Mechanic field straight out of Spell.dbc, so it
// covers *every* spell the client knows — not just the player's
// spellbook — with no caching or network round-trip (Spell.dbc is fully
// resident from boot). This replaces hand-maintained spellID -> mechanic
// tables (e.g. SuperCleveRoidMacros' `CCSpellMechanics`) that addons keep
// solely because vanilla exposes no Lua reader for the field.
//
// Returns:
// - nothing (nil) for an invalid / out-of-range spell ID
// - (0, nil) for a known spell that carries no mechanic
// - (mechanicID, name) otherwise. `name` is the enUS SpellMechanic.dbc
// name (always English, locale-independent — it's a stable token
// consumers match against). nil only if the mechanic has no record /
// no English name; the numeric ID is still returned in that case.
//
// The mechanic numbering is WoW's standard SpellMechanic enum
// (1 = Charm, 5 = Fear, 7 = Root, 12 = Stun, 17 = Polymorph, ...);
// addons map those IDs to their own names as needed.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Spell::Mechanic {
// SpellMechanic.dbc name — always the enUS (index 0) column, regardless
// of client locale. The mechanic name is a stable token consumers key
// off (`"rooted"`, `"stunned"`, …), so it must be identical on every
// client; a localized value would break cross-locale string matching.
// Vanilla only populates the enUS column of this table anyway. Returns
// nullptr if the record is missing or the English slot is empty.
static const char *MechanicName(uint32_t mechanicID) {
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLMECHANIC_RECORDS,
Offsets::VAR_SPELLMECHANIC_COUNT, mechanicID);
if (rec == nullptr)
return nullptr;
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLMECHANIC_NAME);
const char *s = names[0]; // enUS
return (s != nullptr && s[0] != '\0') ? s : nullptr;
}
static int __fastcall Script_GetSpellMechanicByID(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil for invalid / out-of-range spell IDs
const uint32_t mechanicID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MECHANIC);
Game::Lua::PushNumber(L, static_cast<double>(mechanicID));
if (mechanicID == 0) {
Game::Lua::PushNil(L); // known spell, no mechanic
return 2;
}
// Locale name with enUS fallback (nullptr → nil if even English is
// empty); the caller still gets the numeric ID either way.
Game::Lua::PushString(L, MechanicName(mechanicID)); // pushstring(NULL) → pushnil
return 2;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellMechanicByID",
&Script_GetSpellMechanicByID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Mechanic
+88
View File
@@ -0,0 +1,88 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "spell/Mod.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
namespace Spell::Mod {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's class SpellFamilyName, or 0 if it can't be resolved
// (e.g. at the login screen). Gates the SpellMod family match — only
// spells of the player's own class family receive the player's mods, so
// a same-flag-bit spell of another family isn't falsely modified.
uint32_t PlayerSpellFamily() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve("player"));
if (unit == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
const uint8_t *cls = DBC::Record(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT, classByte);
if (cls == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(cls + Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
}
} // namespace
float Apply(const uint8_t *spellRecord, int op, float base) {
if (spellRecord == nullptr)
return base;
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if (familyName == 0 || (attrEx2 & Offsets::SPELL_ATTR_EX2_NO_SPELL_MODS) != 0)
return base;
if (familyName != PlayerSpellFamily())
return base;
const uint64_t familyFlags = *reinterpret_cast<const uint64_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if (familyFlags == 0)
return base;
const auto *flatTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_FLAT_TABLE);
const auto *pctTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_PCT_TABLE);
const int opByteOffset = op * 4;
int flat = 0;
int pct = 0;
for (int slot = 0; slot < Offsets::SPELLMOD_SLOT_COUNT; ++slot) {
if (((familyFlags >> slot) & 1ull) == 0)
continue;
const int byteOff = slot * Offsets::SPELLMOD_SLOT_STRIDE + opByteOffset;
flat += *reinterpret_cast<const int *>(flatTable + byteOff);
pct += *reinterpret_cast<const int *>(pctTable + byteOff);
}
if (flat == 0 && pct == 0)
return base;
int pctTotal = pct + 100;
if (pctTotal < 0)
pctTotal = 0;
return (base + static_cast<float>(flat)) * static_cast<float>(pctTotal) * 0.01f;
}
} // namespace Spell::Mod
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// `Spell::Mod` — the engine's SpellMod (talent / item) system, for the
// LOCAL PLAYER. Replicates `FUN_006e6b30` / `FUN_006e6bf0`: the player's
// flat and percent modifiers are accumulated into two tables indexed
// `[familyFlagBit][op]` (see `VAR_SPELLMOD_*` in Offsets.h). A spell's
// value for a given op is modified by summing, over the bits set in the
// spell's SpellFamilyFlags, the flat and percent entries, then applying
// `value = (value + flat) * (100 + pct) * 0.01`.
//
// This underlies many spell properties — radius, cast time, mana cost,
// duration, range, cooldown — each a distinct `op` (e.g.
// `Offsets::SPELLMOD_OP_RADIUS`). The op numbering is the engine's
// internal one; verify each against the binary before relying on it.
//
// Inherently local-player-only: the client tracks spell mods for the
// player alone, so there's no way to know another caster's talents.
namespace Spell::Mod {
// Returns `base` with the local player's modifiers for `op` applied to
// `spellRecord` (a `Spell.dbc` record pointer). Returns `base` unchanged
// when no modifier matches — wrong spell family, mods disabled on the
// spell, the player can't be resolved, or no relevant talent/item.
float Apply(const uint8_t *spellRecord, int op, float base);
} // namespace Spell::Mod
+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 Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellPowerCost(spellIdentifier)` -> SpellPowerCostInfo[]
//
// Returns an array of power-cost tables, or nil if the spell isn't found
// or has no resource cost. Vanilla spells have exactly one power cost, so
// the array always holds at most one entry.
//
// `cost` is the **effective** cost for the local player — base + level
// scaling + ManaCostPercent-of-resource + the descriptor power-cost
// modifiers + the cost SpellMod (op 14) — via the engine's own cost
// helper `FUN_006e31b0` (the same value the engine charges). So talents
// like reduced-cost spells are reflected. Per-entry fields:
//
// type Enum.PowerType (0 mana, 1 rage, 2 focus, 3 energy,
// 4 happiness) — the spell's PowerType (+0x7C)
// name power token ("MANA", "RAGE", …)
// cost effective cost (see above)
// minCost == cost; vanilla has no "optional" cost component
// costPercent ManaCostPercent (+0x270) — the spell's %-of-base-
// resource cost, or 0 for a flat cost
// costPerSec 0 — vanilla doesn't expose a per-second channel cost
// in these terms
// requiredAuraID 0 — vanilla has no form/aura-conditional costs here
// hasRequiredAura false
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "unit/Power.h"
#include <cstdint>
namespace Spell::PowerCost {
namespace {
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
constexpr int OFF_POWER_TYPE = 0x7C; // int PowerType
constexpr int OFF_MANA_COST_PERCENT = 0x270; // int, % of base resource (0 = flat)
uint32_t EffectiveCost(int spellID) {
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
}
int __fastcall Script_GetSpellPowerCost(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return 0; // nil — spell not found
const uint32_t cost = EffectiveCost(spellID);
if (cost == 0xFFFFFFFF)
return 0; // engine error (no player context, etc.)
const int powerType = *reinterpret_cast<const int *>(rec + OFF_POWER_TYPE);
const int costPercent = *reinterpret_cast<const int *>(rec + OFF_MANA_COST_PERCENT);
if (cost == 0 && costPercent == 0)
return 0; // nil — no resource cost
// SpellPowerCostInfo[] — a one-element array: outer[1] = entry.
Game::Lua::NewTable(L); // outer
Game::Lua::PushNumber(L, 1.0); // key 1
Game::Lua::NewTable(L); // entry (top)
Game::Lua::SetFieldNumber(L, "type", static_cast<double>(powerType));
Game::Lua::SetFieldString(L, "name", Unit::Power::PowerTypeToken(powerType));
Game::Lua::SetFieldNumber(L, "cost", static_cast<double>(cost));
Game::Lua::SetFieldNumber(L, "minCost", static_cast<double>(cost));
Game::Lua::SetFieldNumber(L, "costPercent", static_cast<double>(costPercent));
Game::Lua::SetFieldNumber(L, "costPerSec", 0);
Game::Lua::SetFieldNumber(L, "requiredAuraID", 0);
Game::Lua::SetFieldBool(L, "hasRequiredAura", false);
Game::Lua::SetTable(L, -3); // outer[1] = entry
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellPowerCost", &Script_GetSpellPowerCost);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::PowerCost
+120
View File
@@ -0,0 +1,120 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellRadius(spellID)` and the parallel
// `GetSpellRadius(slot, bookType)` -> (baseRadius, modifiedRadius)
//
// Returns the spell's AOE radius in yards, or nil for a non-AOE spell
// (no effect carries a radius). Two values:
// - baseRadius — the raw SpellRadius.dbc value (caster-independent,
// what an AOE tracker watching other units wants)
// - modifiedRadius — baseRadius with the LOCAL PLAYER's spell modifiers
// (talents / items, e.g. a Mage's Arctic Reach)
// applied. Equal to baseRadius when the player has
// no matching modifier.
//
// Two signatures mirroring the other dual-signature spell globals
// (`GetSpellInfo`, `SpellHasRange`, etc.): the namespaced form takes a
// modern spell *identifier* (numeric ID or name), the bare global takes
// the vanilla `(slot, bookType)` spellbook position. `bookType` is the
// `GetSpellName` convention: `"pet"` → pet book, anything else (or
// omitted) → player book.
//
// baseRadius reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]` and
// resolves each into `SpellRadius.dbc`, taking the largest base radius
// across the three effects. No caster-level scaling (the engine's
// FUN_006e6350 also adds `radiusPerLevel * casterLevel`, which needs a
// unit context; modern `GetSpellRadius` reports the base value).
//
// modifiedRadius applies the local player's SpellMods for op 6 (radius)
// via `Spell::Mod::Apply`. It is inherently local-player-only — the
// client tracks spell mods for the player alone, so there's no way to
// know another caster's talents.
//
// All DBCs / mod tables are resident from boot, so the lookup is
// synchronous with no caching.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "spell/Mod.h"
#include <cstdint>
#include <cstring>
namespace Spell::Radius {
// Largest base radius across the spell's effects, or a negative sentinel
// (< 0) if no effect has a radius.
static float MaxBaseRadius(const uint8_t *record) {
float best = -1.0f;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int radiusIndex = *reinterpret_cast<const int *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
const uint8_t *rad = DBC::Record(Offsets::VAR_SPELL_RADIUS_RECORDS,
Offsets::VAR_SPELL_RADIUS_COUNT,
static_cast<uint32_t>(radiusIndex));
if (rad == nullptr)
continue;
const float r = *reinterpret_cast<const float *>(rad + Offsets::OFF_SPELL_RADIUS_VALUE);
if (r > best)
best = r;
}
return best;
}
// Pushes (baseRadius, modifiedRadius) for `spellID`, or nil (0 returns)
// for an invalid / non-AOE spell.
static int PushRadiusForSpellID(void *L, int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil
const float base = MaxBaseRadius(record);
if (base < 0.0f)
return 0; // nil — not an AOE spell
Game::Lua::PushNumber(L, static_cast<double>(base));
Game::Lua::PushNumber(L,
static_cast<double>(Spell::Mod::Apply(record, Offsets::SPELLMOD_OP_RADIUS, base)));
return 2;
}
// `C_Spell.GetSpellRadius(spellID)` — modern spell-identifier form.
static int __fastcall Script_C_Spell_GetSpellRadius(void *L) {
return PushRadiusForSpellID(L, Spell::Arg::ResolveSpellID(L, 1));
}
// `GetSpellRadius(slot, bookType)` — vanilla-style positional shape.
static int __fastcall Script_GetSpellRadius(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
int bookType = 0;
if (Game::Lua::IsString(L, 2)) {
const char *s = Game::Lua::ToString(L, 2);
if (s != nullptr && _stricmp(s, "pet") == 0)
bookType = 1;
}
return PushRadiusForSpellID(L, Spell::Lookup::SpellbookSlotToID(slot, bookType));
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRadius",
&Script_C_Spell_GetSpellRadius);
Game::Lua::RegisterGlobalFunction("GetSpellRadius", &Script_GetSpellRadius);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Radius
+17 -8
View File
@@ -25,11 +25,12 @@ namespace Spell::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
// Spell.dbc record field offsets we read for usability. Fully documented
// in CLAUDE.md.
constexpr int OFF_SPELL_POWER_TYPE = 0x7C; // int8: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost (fallback only)
// Returns true if the player knows `spellID` per the engine's spell-
// knowledge bitmap at `[VAR_PLAYER_SPELL_BITMAP]`. Same check
@@ -135,8 +136,9 @@ bool HasReagents(void *L, const uint8_t *record) {
// 3. Player descriptor is reachable (post-login).
// 4. Player is alive (HEALTH > 0).
// 5. Spell is not on cooldown (engine cooldown helper).
// 6. Player has enough power for `ManaCost` of the spell's
// `PowerType`. Only this failure flips `noMana=true`.
// 6. Player has enough power for the spell's *effective* cost (talent
// reductions applied) of its `PowerType`. Only this flips
// `noMana=true`.
// 7. Player has all required reagents in bags (Reagent[8] /
// ReagentCount[8] from the spell record).
//
@@ -170,16 +172,23 @@ Usability ComputeUsability(void *L, int spellID) {
if (IsOnCooldown(spellID))
return r;
// Mana check is the only one that flips noMana.
// Mana check is the only one that flips noMana. Use the engine's
// effective-cost helper (op-14 SpellMod + descriptor power-cost mods +
// ManaCostPercent), so talent reductions like Frost Channeling count —
// not just the base ManaCost. Falls back to base if the engine can't
// resolve a cost (shouldn't happen here: the player descriptor
// resolved above, so it has a player context).
const int powerType = static_cast<int>(*reinterpret_cast<const int8_t *>(
record + OFF_SPELL_POWER_TYPE));
if (powerType >= 0 && powerType <= 4) {
const int manaCost = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + OFF_SPELL_MANA_COST));
if (manaCost > 0) {
auto getCost = reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST);
uint32_t cost = getCost(spellID, 0 /* local player */);
if (cost == 0xFFFFFFFF)
cost = *reinterpret_cast<const uint32_t *>(record + OFF_SPELL_MANA_COST);
if (cost > 0) {
const int currentPower = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + powerType * 4);
if (currentPower < manaCost) {
if (currentPower < static_cast<int>(cost)) {
r.noMana = true;
return r;
}
+69 -42
View File
@@ -11,6 +11,8 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "unit/Identity.h"
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
@@ -44,15 +46,29 @@ using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
// `FUN_00468550() != 0` for the same reason — the GUID isn't ready
// yet. Addons that need the player GUID at boot must wait for
// `PLAYER_LOGIN`. See the trace notes in commit history.
static uint64_t ResolveTokenGUID(const char *token) {
if (token != nullptr && _stricmp(token, "player") == 0) {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t PlayerGuid() {
auto *player = *reinterpret_cast<uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR));
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
player + Offsets::OFF_LOCAL_PLAYER_GUID);
}
uint64_t GuidForObject(const void *unitObject) {
if (unitObject == nullptr)
return 0;
const auto *unit = static_cast<const uint8_t *>(unitObject);
const uint8_t *block = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_GUID_PTR);
if (block == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(block);
}
uint64_t GuidForToken(const char *token) {
if (token != nullptr && _stricmp(token, "player") == 0)
return PlayerGuid();
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
@@ -92,7 +108,7 @@ static int __fastcall Script_UnitGUID(void *L) {
if (token == nullptr)
return 0;
const uint64_t guid = ResolveTokenGUID(token);
const uint64_t guid = GuidForToken(token);
if (guid == 0)
return 0;
@@ -127,25 +143,20 @@ static int __fastcall Script_UnitGUID(void *L) {
//
// Returns `nil` if the input string isn't a parseable GUID, or the
// GUID is zero, or no token currently maps to it.
static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t target = 0;
if (guidStr == nullptr || !Guid::Parse(guidStr, &target) || target == 0)
return 0;
const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
if (target == 0)
return nullptr;
auto set = [&](const char *token) -> const char * {
std::snprintf(buf, bufSize, "%s", token);
return buf;
};
auto check = [&](const char *token) -> bool {
if (ResolveTokenGUID(token) != target)
return false;
Game::Lua::PushString(L, token);
return true;
return GuidForToken(token) == target;
};
if (check("player")) return 1;
if (check("pet")) return 1;
if (check("player")) return set("player");
if (check("pet")) return set("pet");
// Cap each indexed-token scan at the actual populated slot
// count, read straight out of engine memory. Solo skips all 88
@@ -170,22 +181,21 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (raidCount > Offsets::RAID_MAX_SLOTS)
raidCount = Offsets::RAID_MAX_SLOTS;
char buf[16];
for (int i = 1; i <= partyCount; ++i) {
std::snprintf(buf, sizeof buf, "party%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "party%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= partyCount; ++i) {
std::snprintf(buf, sizeof buf, "partypet%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "partypet%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= raidCount; ++i) {
std::snprintf(buf, sizeof buf, "raid%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "raid%d", i);
if (GuidForToken(buf) == target) return buf;
}
for (int i = 1; i <= raidCount; ++i) {
std::snprintf(buf, sizeof buf, "raidpet%d", i);
if (check(buf)) return 1;
std::snprintf(buf, bufSize, "raidpet%d", i);
if (GuidForToken(buf) == target) return buf;
}
// Visible nameplates. The ordered list (see
@@ -198,18 +208,35 @@ static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (plateGuid == 0)
break;
if (plateGuid == target) {
std::snprintf(buf, sizeof buf, "nameplate%d", i);
Game::Lua::PushString(L, buf);
return 1;
std::snprintf(buf, bufSize, "nameplate%d", i);
return buf;
}
}
if (check("target")) return 1;
if (check("focus")) return 1;
if (check("npc")) return 1;
if (check("mouseover")) return 1;
if (check("target")) return set("target");
if (check("focus")) return set("focus");
if (check("npc")) return set("npc");
if (check("mouseover")) return set("mouseover");
return 0;
return nullptr;
}
static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t target = 0;
if (guidStr == nullptr || !Guid::Parse(guidStr, &target) || target == 0)
return 0;
char buf[32];
const char *token = TokenFromGUID(target, buf, sizeof buf);
if (token == nullptr)
return 0;
Game::Lua::PushString(L, token);
return 1;
}
static void RegisterLuaFunctions() {
+55
View File
@@ -0,0 +1,55 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
#pragma once
#include <cstddef>
#include <cstdint>
// Unit identity helpers shared across modules. The Lua surface
// (`UnitGUID` / `UnitTokenFromGUID`) lives in `Identity.cpp`; these are the
// reusable C++ cores other modules call directly (e.g. `Aura::Data`
// resolving an aura's caster GUID to a `sourceUnit` token).
namespace Unit::Identity {
// The local player's 64-bit GUID, read from the canonical local-player
// pointer (`VAR_LOCAL_PLAYER_PTR + OFF_LOCAL_PLAYER_GUID`). Independent of
// object-manager state, so it stays valid across zone transitions / brief
// CGPlayer rebuilds. Returns 0 before login (pointer null) or before the
// GUID is populated. This is the canonical "who am I" read — prefer it over
// re-dereferencing the globals inline.
uint64_t PlayerGuid();
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
// Returns 0 if `unitObject` or the block pointer is null. Use this for any
// "GUID of this object pointer" read so callers stay consistent (the aura
// caster cache keys on it from both the lookup and the OnAuraAdded hook).
uint64_t GuidForObject(const void *unitObject);
// Resolves a unit token (`"player"`, `"target"`, `"party3"`, …) to its
// 64-bit GUID, or 0 if the token currently maps to nothing. `"player"` is
// read from the canonical local-player pointer so it works regardless of
// object-manager state; everything else goes through the engine's
// token→GUID resolver (so out-of-range party/raid members resolve too).
// NOTE: raises a Lua error for tokens the engine doesn't recognize — only
// pass known unit tokens.
uint64_t GuidForToken(const char *token);
// Best-effort reverse lookup: given a unit GUID, writes the first unit
// token currently mapped to it into `buf` and returns `buf`, or returns
// nullptr if no known token points at it (or `guid` is 0). The mapping is
// unstable across frames — callers should treat the result as a snapshot.
// `buf` should be at least 32 bytes.
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
} // namespace Unit::Identity
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
// Shared world-position read for any CGObject-derived unit. The
// position comes from the object's `GetPosition` virtual (vtable
// slot 5) — the same one `CheckInteractDistance` / `UnitInRange` use.
// Header-only so callers (UnitInRange, CastAtUnit, UseAtUnit) don't
// each re-derive the vtable-slot math.
namespace Unit::Position {
// Resolves a unit token ("player", "target", "party1", "mouseover",
// …) to a `CGUnit *`. Returns nullptr for a recognized-but-absent
// token (e.g. "party5" with no such member). NOTE: the engine's
// resolver raises a Lua error for genuinely unrecognized token
// strings — same contract as `UnitHealth("garbage")` — so only feed
// it real unit tokens, or strings you're willing to let error.
inline void *ResolveToken(const char *token) {
if (token == nullptr)
return nullptr;
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
// Reads `obj`'s world position into `out[3]` (x, y, z). Calls
// `obj->vtable[5](outBuf)`; the returned float* may equal `outBuf`
// (object filled it) or point at a cached position field — either
// way the values land in `out`. Returns false if `obj` is null or
// the virtual yields no position (object has no known position yet).
inline bool Read(void *obj, float out[3]) {
if (obj == nullptr)
return false;
auto **vtable = *reinterpret_cast<void ***>(obj);
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
auto fn = reinterpret_cast<GetPosition_t>(
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
float *p = fn(obj, out);
if (p == nullptr)
return false;
if (p != out) {
out[0] = p[0];
out[1] = p[1];
out[2] = p[2];
}
return true;
}
// Convenience: resolve a token and read its position in one step.
inline bool ReadToken(const char *token, float out[3]) {
return Read(ResolveToken(token), out);
}
} // namespace Unit::Position
+18 -16
View File
@@ -33,11 +33,29 @@
#include "Game.h"
#include "Offsets.h"
#include "unit/Power.h"
#include <cstdint>
namespace Unit::Power {
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
// can't appear on a 1.12 unit but we map them anyway in case a
// private server pushes one through.
const char *PowerTypeToken(int type) {
switch (type) {
case 0: return "MANA";
case 1: return "RAGE";
case 2: return "FOCUS";
case 3: return "ENERGY";
case 4: return "HAPPINESS";
case 5: return "RUNES";
case 6: return "RUNIC_POWER";
}
return "UNKNOWN";
}
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
@@ -68,22 +86,6 @@ uint32_t PowerDivisor(int type) {
return table[type];
}
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
// can't appear on a 1.12 unit but we map them anyway in case a
// private server pushes one through.
const char *PowerTypeToken(int type) {
switch (type) {
case 0: return "MANA";
case 1: return "RAGE";
case 2: return "FOCUS";
case 3: return "ENERGY";
case 4: return "HAPPINESS";
case 5: return "RUNES";
case 6: return "RUNIC_POWER";
}
return "UNKNOWN";
}
// `Enum.PowerType` — modern Blizzard-style enum table. Slot 4 is
// `Happiness` in 1.12 (pet happiness 0..3); modern retail relabels
+23
View File
@@ -0,0 +1,23 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Unit::Power {
// Modern power-type token for a PowerType value: 0 MANA, 1 RAGE, 2 FOCUS,
// 3 ENERGY, 4 HAPPINESS, 5 RUNES, 6 RUNIC_POWER, anything else "UNKNOWN".
// Used by `UnitPowerType`'s second return and by spell power-cost APIs.
const char *PowerTypeToken(int type);
} // namespace Unit::Power
+5 -39
View File
@@ -15,42 +15,17 @@
// uses, applied with a fixed 40-yard threshold.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
#include <cstring>
namespace Unit::Range {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
void *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
// Calls `obj->vtable[5](outBuf)`. The returned float* may equal
// outBuf (the object filled it directly) or point at a cached
// position field on the object — either way, deref it for x/y/z.
// Returns nullptr if the object's vtable virtual returns null (no
// known position for this object yet).
float *ReadPosition(void *obj, float outBuf[3]) {
if (obj == nullptr)
return nullptr;
auto **vtable = *reinterpret_cast<void ***>(obj);
auto fn = reinterpret_cast<GetPosition_t>(
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
return fn(obj, outBuf);
}
int __fastcall Script_UnitInRange(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
@@ -69,19 +44,10 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
void *unit = ResolveUnit(token);
void *player = ResolveUnit("player");
if (unit == nullptr || player == nullptr) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
float unitBuf[3] = {};
float playerBuf[3] = {};
const float *unitPos = ReadPosition(unit, unitBuf);
const float *playerPos = ReadPosition(player, playerBuf);
if (unitPos == nullptr || playerPos == nullptr) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
+85
View File
@@ -0,0 +1,85 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser 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 Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
// italic text under their name in the tooltip): "Innkeeper",
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
// only one value, so addons that want the subtitle have to roll their
// own — until now.
//
// Returns `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Player units — they don't have a row in the creature cache.
// - NPCs whose creature data hasn't synced yet (rare; happens when
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
// hasn't arrived).
// - NPCs with no subname (most non-vendor / non-trainer creatures).
//
// Raises a Lua error on missing / non-string `unit` argument — same
// semantics as the engine's `UnitName`.
//
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
// `[+0x10]` (subname C string). Verified path; same one
// VanillaMinimapTracking's blip subname filter uses.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SubName {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitSubName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr)
return 0;
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
if (cacheRow == nullptr)
return 0;
auto *subName = *reinterpret_cast<const char *const *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
if (subName == nullptr || *subName == '\0')
return 0;
Game::Lua::PushString(L, subName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SubName