Compare commits

...
100 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
Brues a7ffe1c704 max stack size is the 7th return 2026-06-08 11:25:50 -05:00
Brues 9f75737882 GetSendMailItemLink / GetInboxItemLink 2026-06-07 23:45:14 -05:00
Brues 10d5790975 always load enUS strings as fallback 2026-06-07 19:09:10 -05:00
Brues b571483c5f AoE loot testing
commit 24ab88fac3c9da1d83e173aefacad92ad3b0ab2c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sun Jun 7 15:11:27 2026 -0500

    C_Loot docs

commit aca61c0cb7b90cb84c2d782490de38a2db099004
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 21:47:33 2026 -0500

    loot scan

commit bf44cb9acc07504336c54323074126738ed623a0
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 21:30:29 2026 -0500

    aoe loot test

commit 648cb053a9232ea9eef17e48ff45424c9eed5bbb
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Thu Jun 4 19:06:18 2026 -0500

    LootUnit/GetNearbyLootableUnits
2026-06-07 15:18:56 -05:00
Brues 0a79016b0f CI: split configure into tag/branch steps so branch builds don't pass branch name as tag
CMakeLists rejects CLASSICAPI_TAG values that aren't empty or
vMAJOR.MINOR.PATCH. Branch pushes were passing `github.ref_name`
(= the branch name, e.g. "master") and failing the regex.
2026-06-07 14:57:37 -05:00
Brues 3c4f24192a CI: build every push, release only on tag
Was tag-only. Now builds on every push to any branch, uploading the
DLL as a workflow artifact (ClassicAPI-<sha>, ~90-day retention) so
pre-release DLLs are downloadable from the run page without cutting
a tag. Tag pushes still build + stamp the embedded TOC version +
publish the GitHub release.

Renamed release.yml -> build.yml to match the broader role.
2026-06-07 14:54:10 -05:00
Brues 93b4dc5db8 PlayerBuffExpirationSeconds: read tick value as uint32, not signed int
The engine stores expiration timestamps as GetTickCount-derived uint32
milliseconds. We were reading as signed int, then bailing on `<= 0` to
catch the "no timing" sentinel of 0. Once system uptime crossed 2^31 ms
(~24.86 days) the high bit flipped, signed reads went negative, and
every active buff started reporting expirationTime = 0 while
GetTime() kept working through a different code path.

Switch to uint32_t with `== 0` sentinel check. The unsigned read flows
correctly through GetTickCount's 2^32-ms (~49.7-day) wrap because
addon math (`expirationTime - GetTime()`) operates on values that wrap
together — same as the engine's own `expirationTable[slot] - currentMs`
subtraction.
2026-06-07 00:52:59 -05:00
Brues d786c607b8 C_Container.MoveItem: route count == srcStack through swap path
Vanilla's CMSG_SPLIT_ITEM rejects splits where count == srcStack
(EQUIP_ERR_COULDNT_SPLIT_ITEMS — would leave source empty). The
drag-and-drop UI's "drop whole stack on matching stack" goes through
CMSG_SWAP_ITEM, which the server merges for same-item destinations.

In MoveCount, read ITEM_FIELD_STACK_COUNT off the resolved source
CGItem and route count == srcStack to Item::Swap::Containers. Also
return false locally for count > srcStack (server would reject anyway,
fail fast). count < srcStack keeps the existing SPLIT path.
2026-06-06 09:41:48 -05:00
Brues 598c66894e Polyfill UNIT_FACTION fire-coverage on 5 paths the 1.12 engine skips
3.3.5+ fires UNIT_FACTION("player") on rep-state changes that vanilla
silently drops. Hook each path's inner setter / SMSG handler and fire
the event post-original via the engine's printf-style dispatcher:

- FactionToggleAtWar — fire if rep slot's flag byte changed
- SetFactionInactive / SetFactionActive — same shape
- SMSG_SET_FACTION_STANDINGS — fire if any of 64 flag bytes changed
  (catches AT_WAR side-effect flips on standing threshold crossings)
- SMSG_SET_FACTION_ATWAR — unconditional after byte write
- SMSG_INITIALIZE_FACTIONS — fire after initial sync at login

Hoist the event-table strcmp walk into Event::Custom::LookupByName
(sibling to Lookup, which only consults our reservation cache). Replaces
duplicated walks in event/Util.cpp::IsKnownEventName and the polyfill.
2026-06-05 20:50:51 -05:00
Brues 75b4ea3863 Update README.md 2026-06-04 17:38:23 -05:00
Brues 5f66e381d2 Restore Lua 5.0 coroutine.* library
Re-registers the standard five entries (create / resume / yield /
status / wrap) on the global `coroutine` table. The C-level
machinery (lua_resume, lua_yield, lua_newthread, threads as tag 8)
was always linked into the engine; only the script-side library
was stripped from the registration tables at build time.

Two WoW-specific quirks discovered along the way and worked
around:

- `lua_resume` returns 1 for BOTH yield and caught runtime error.
  Disambiguated by checking `co->ci > co->base_ci` — a yielded
  coroutine still has its body frame on the call stack; an
  errored one has been unwound to base_ci.

- The byte at L+0x31 is `allowhook` (saved/restored around
  protected calls), not `L->status` as it initially looked.
  Status reading is replaced by the ci comparison above.
2026-06-04 16:23:55 -05:00
Brues 4dd892297d removed unused GlobalStrings.lua 2026-06-04 15:43:19 -05:00
Brues 51cb7a5674 Add C_Item.GetItemData / GetItemDataByID
Single-call kitchen-sink reader for the cached `ItemStats_C` record.
Returns a table with ~50 fields covering everything the cache holds:
identity (name, description, icon, class/subclass/equipLoc strings),
quality + bind type + decoded flag bits (isConjured, isOpenable,
isLootable, isWrapper), stacking, economy, requirements, equipment
stats (sparse map keyed by statType), resistances, weapon damage
arrays, spell slots, and the misc fields (lockID, itemSet, pageText,
startQuest, area/map, etc.).

Returns nil for uncached / invalid input — passive reader, no cache
warming. Caller wanting to ensure load should `RequestLoadItemData`
and listen for `ITEM_DATA_LOAD_RESULT`.

Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
2026-06-04 14:39:01 -05:00
Brues cc0bf769c4 Dedup CacheFetch callbacks via _GetRecord's built-in walk
Bursts of `WarmCache` calls for the same uncached itemID (e.g. Bagnon
re-enumerating a slot while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in
flight) were each appending a fresh `Implicit` callback to the
entry's pending list. When the response landed the engine walked the
list and fired our callback N times, surfacing as duplicate
`GET_ITEM_INFO_RECEIVED` events for the same itemID.

`_GetRecord`'s 6th arg (`param_5` in the decompilation, previously
documented as the unused `requestIfMissing` bool) controls whether
the engine dedups appends. With `0` it always appends; with non-zero
it walks the existing list and skips the append on a matching
`(callback, userData)` node. We always have the same callback ptr and
encode the itemID in userData, so flipping the bit collapses repeat
warmups for the same item to a single registration.
2026-06-04 13:34:14 -05:00
Brues ad13440743 Item cache: hook response handler instead of racing the engine
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items
permanently uncached when pfQuest called it at addon-load /
PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs:

1. `GetItemInfoInstant` returned no values for uncached items, so
   `ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)`
   crashed with "table index is nil".
2. `DoesItemExistByID` returned false for uncached items, which made
   `IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad`
   on cache state — modern semantic is "is this a valid ID", independent
   of cache.
3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our
   own callback at addon-load. That created a cache entry before the
   engine's natural inventory prefetch had a chance to run; the engine
   then saw the entry already existed and skipped its own SMSG, leaving
   the entry permanently pending. `GetItemInfo` retries didn't help —
   subsequent `_GetRecord` calls just appended callbacks to the dead
   list, no new query was sent.

Fixes:

- `GetItemInfoInstant` always pushes the itemID (echoed from input) and
  the remaining 6 fields when cached / nils when not. No cache touch.
- `DoesItemExistByID` returns `itemID > 0`. No cache touch.
- `RequestLoadItemData` tracks itemIDs in a pending set without touching
  the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's
  `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep
  pending after the engine fills entries — observing the natural
  prefetch instead of racing it. `WorldTick` only handles escalation
  (after ~60 ticks, kick a query for items the engine doesn't prefetch
  itself, e.g. quest rewards) and the hard timeout.

Also fix the truthiness bug in modern (id, success) events:
`Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a
Lua number — both 0 and 1 are truthy, so `if success then` fired the
success branch on failed loads. New `Event::Custom::FireIdSuccess`
pushes `1` for success and `nil` for failure (via the engine's
`lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`,
`GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
2026-06-04 13:15:59 -05:00
Brues 999a5846c4 Macro tagging: extract dispatch hub, add C_Spell.CastAtCursor pattern
Hoists the macro-parser hook + long-line guard + per-line walk out
of Spell::CastNoToggle into a new Spell::MacroPrimarySpell module
that dispatches to (prefix, extractor) pairs registered by feature
modules. The body walk now visits each line once and checks every
registered prefix at each position; extractors only parse args
after a matched prefix.

Registers a pattern for C_Spell.CastAtCursor(<spellID>) so macros
like `/run C_Spell.CastAtCursor(10)` get tagged with the spellID
the same way `/cast Blizzard` does, driving action-bar cooldown
overlays and auto-repeat highlight in pfUI etc.
2026-06-04 02:29:58 -05:00
Brues 9b5d12c8a1 Document C_Spell.CastAtCursor / C_Item.UseAtCursor 2026-06-03 14:55:00 -05:00
Brues d1bab7a3a4 Add C_Spell.CastAtCursor / C_Item.UseAtCursor
Vanilla 1.12.1 requires a manual click on the AoE reticle to land a
ground-target spell. This pair of functions fires the cast (or
on-use item spell) and immediately commits placement at the
cursor's current world position, matching modern `[@cursor]`
semantics:

  C_Spell.CastAtCursor(spellID)
  C_Item.UseAtCursor(itemInfo)

Chains the engine's own primitives — name-resolver to spellbook
slot, cast dispatcher, then a screen→world raycast via the existing
mouseover machinery and the placement-commit function. Cancels
cleanly when the spell isn't ground-target or the cursor is over
UI / off-screen.

Also extracts the bag-search pattern (used by `UseAtCursor` and
existing by-name action APIs) into `Item::Location::FindByArgInBags`,
alongside the existing `FindByItemID` / `FindByGUID` family.
2026-06-03 14:04:12 -05:00
Brues e657bac266 Suppress fatal-error code 10 so engine accepts ClassicAPI's file overrides
Vanilla 1.12.1 hashes FrameXML.toc + Bindings.xml + each addon's
contents and compares against signature blobs (Interface\FrameXML.sig
+ per-addon stored hash). ClassicAPI's file-read hook diverges those
in memory — Bindings.xml splice and the embedded !!!ClassicAPI addon
— so the engine fires FUN_00401560(10) and terminates with "Your
game interface files are corrupt".

Hook FUN_00401560 and drop calls with code 10. Other codes (login
failures, etc.) pass through unmodified. Stock vanilla binaries
(WoW.exe without a runtime patcher) now load ClassicAPI without
needing TLS-bootloader-style modifications.
2026-06-03 03:43:43 -05:00
Brues 0248661bb7 true localization 2026-06-03 03:33:00 -05:00
Brues 56fef8ec08 Spice section is no longer hardcoded 2026-06-03 00:41:39 -05:00
Brues 09ac68e674 No inline xml in c++ 2026-06-03 00:37:05 -05:00
Brues 19ad6e6514 update docs 2026-06-02 18:06:29 -05:00
Brues 41bb687608 Add FOCUSTARGET / TARGETFOCUS bindings under TARGETING
Splice into the engine's FrameXML Bindings.xml at load time via a
file-read hook so the new bindings receive display indices from the
same running counter as native bindings — landing visually inside the
TARGETING block instead of orphaned at the bottom (where addon-side
Bindings.xml entries always render, regardless of header="TARGETING").
2026-06-02 17:29:49 -05:00
Brues d9cc3f9d7e sort Items category 2026-06-02 16:51:15 -05:00
Brues 6b19c8bbb5 move useitembyname to Item section 2026-06-02 16:22:54 -05:00
Brues 09e57c3d97 UseItemByName now accepts a unit arg 2026-06-02 14:51:05 -05:00
Brues 2f3c67be1a Script_EquipmentSetContainsLockedItems: Missing items are not locked 2026-05-31 12:42:40 -05:00
Brues f94af6cb73 Fix C_Item.GetItemUniqueness* return shapes
The two functions have different signatures in modern (Classic Era
1.15.x) WoW, verified against BlizzardInterfaceCode's generated
ItemDocumentation.lua:

  GetItemUniqueness(itemLocation) → (limitCategory, limitMax)
  GetItemUniquenessByID(itemID)   →
      (isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)

We were returning a 3-tuple in the wrong order for both. For a
"Unique (10)" item the byID variant now correctly returns
(true, nil, 10, nil) instead of (10, nil, 0).
2026-05-31 10:20:19 -05:00
Brues 1d370ada65 GetInventoryItemsForSlot: filter by player proficiency
Mail helms were showing up for cloth classes — the function only
checked invType→slot shape, not whether the player could actually
equip the item.

Adds a proficiency gate using the engine's per-itemClass mask table
at [0x00C4D4A0], populated by the SMSG_SET_PROFICIENCY handler at
0x005E7B70. `table[itemClass] & (1 << subclass)` is the same lookup
4.3.4's Script_GetInventoryItemsForSlot does (via FUN_00553AD0 ->
DAT_00DD4D38), and the same source the engine's own tooltip red-text
coloring and cursor-drop equip check read from. Covers both armor
and weapon proficiencies in one mask lookup.
2026-05-31 00:54:36 -05:00
Brues 35c78ea6be C_AddOns.GetAddOnName returns nil for addons that don't exist 2026-05-30 23:13:32 -05:00
Brues ad83eb9575 IsMouseButtonDown
Adds the modern (2.0.1+) query function backed by the same
WH_GETMESSAGE hook stream that drives GLOBAL_MOUSE_DOWN/UP — state
flips at the exact same point the events fire. Accepts the 1..5 ID
form, the "LeftButton".."Button5" name form, or no arg for "any
button held".
2026-05-30 22:36:30 -05:00
Brues 8995e82627 UseEquipmentSet: pre-snapshot locations, two-phase execution
Engine InvMgr state lags behind our atomic-swap packets (server has
to ACK before CGItem moves register locally), so mid-loop FindGUID
lookups returned stale locations and could undo prior swaps.

Mirrors 4.3.4's `Script_UseEquipmentSet` at 0x009A15D0, which walks
all 19 set slots up front to build an action list before any
execution. Combined with two-phase sequencing:

  1. paperdoll→paperdoll only — keeps bag state untouched, supports
     chained rotations via a virtual-paperdoll walk
  2. bag→paperdoll — atomic swap displaces current occupants into
     the source bag slot, never strands a future set item
  3. unequip slots the set wants empty into pre-snapshotted free
     bag slots

Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
2026-05-30 22:23:32 -05:00
Brues 94b521881b remove max equipment sets 2026-05-30 21:38:49 -05:00
Brues 634a440def set addon toc to dev 2026-05-30 21:38:33 -05:00
brues-code 4dce58a457 Fix State section formatting in README.md 2026-05-30 19:54:31 -05:00
Brues 99e9621590 IsLoggedIn 2026-05-30 18:54:30 -05:00
Brues e8fc583f19 Expand GetQuestDetails: srcItemID, completionText, poi, objective text
Trace SMSG_QUEST_QUERY_RESPONSE serializer at 0x006DBCEC to map the
remaining cache-block field offsets, exposing four new fields plus
per-requirement override text. Empirical dumps across three quests
confirmed the +0x14..+0x24 slots (would-be SuggestedPlayers /
LimitTime / RequiredRaces / RequiredSkill / RequiredSkillValue) are
never populated by the vanilla server — those constraints are
enforced server-side and never broadcast, so addons needing them
must use an external DB.

Also corrects OFF_SORT_PTR -> OFF_ZONE_OR_SORT (int32, positive =
zone areaID, negative = -QuestSort.dbc row).
2026-05-30 17:15:18 -05:00
Brues 03f63d391a GameTooltip_Hide 2026-05-30 16:50:10 -05:00
Brues 6df403444c C_QuestLog.GetQuestDetails 2026-05-30 16:21:12 -05:00
Brues cb7953b503 GameTooltip:SetItemByGUID
Modern method that renders the tooltip for a specific item *instance*
identified by GUID — same string `C_Item.GetItemGUID` returns. Distinct
from `SetItemByID` (which uses base `ItemSparse` data with no
per-instance state): this path goes through the live CGItem, so the
tooltip includes enchant lines, random-suffix-decorated name +
bonuses, and locked/broken state.

Implementation: parse GUID via `Item::Location::ParseGUIDString`,
resolve through the engine's `FUN_OBJECT_RESOLVE_BY_GUID`, then build
the per-instance hyperlink via `Item::Link::FromCGItem`. Strip the
`|cff…|H…|h[…]|h|r` decoration to the bare `item:N:enchant:gem:gem:
gem:gem:suffix:unique` payload (SetHyperlink rejects the decorated
form with "Unknown link type") and dispatch to the engine's existing
SetHyperlink. Closes the loop with `GameTooltip:GetItem()`, which
returns the same dressed link back.
2026-05-30 15:40:06 -05:00
Brues 774df8d0d4 UseEquipmentSet unequips slots the set wants empty
Previously only `GUID_IGNORED` and `GUID_EMPTY` were both treated as
"skip this slot," so equipment sets with cleared (but not ignored)
slots couldn't be used to unequip — running a "naked" set returned
`true` but left the player's gear on.

Now `GUID_EMPTY` triggers a paperdoll-to-bag move. Implementation:

- `Item::Swap::ToBag` — new atomic primitive (paperdoll → bag) using
  the same `FUN_INVENTORY_SWAP` engine helper as the equip variants,
  just with player+slot as the source side and a bag-encoded
  destination.
- `Script_UseEquipmentSet` snapshots all empty bag slots at entry
  and consumes them sequentially. Snapshotting up front avoids the
  client-side stale-state collision where iterating `ResolveBag`
  per unequip would keep picking the same "first empty" slot — the
  client's bag state doesn't update between packet sends, so the
  second packet would target a slot the first had already filled,
  and the server would try to swap the previously-unequipped item
  back onto the wrong paperdoll slot ("This item cannot be
  equipped").
2026-05-30 15:17:01 -05:00
Brues 36e1aa87e4 C_AddOns.IsAddOnLoaded 2026-05-30 14:53:45 -05:00
Brues 40532aca8e Ensure !!!ClassicAPI is loaded early 2026-05-30 14:31:06 -05:00
Brues 8592254b2d Fixed items equipped string in SetEquipmentSet tooltip 2026-05-30 13:59:28 -05:00
brues-code 289f82a7e7 Remove IconDataProviderMixin documentation
Removed the `IconDataProviderMixin` section and its details from the API documentation.
2026-05-30 13:53:22 -05:00
115 changed files with 12419 additions and 1513 deletions
@@ -1,9 +1,9 @@
name: Release
name: Build
on:
push:
tags:
- 'v*'
branches: ['**']
tags: ['v*']
permissions:
contents: write
@@ -16,12 +16,8 @@ jobs:
with:
submodules: recursive
# Stamp the addon's TOC version from the tag (drops the leading `v`)
# BEFORE CMake configure, so the embed step picks up the stamped TOC.
# The DLL bundles AddOns/!!!ClassicAPI/ at build time — see the
# `embed_addon` custom command in CMakeLists.txt and the file-read
# hook in src/addons/Embedded.cpp.
- name: Stamp embedded TOC version
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
@@ -29,13 +25,26 @@ jobs:
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
- name: Configure
- name: Configure (tag)
if: startsWith(github.ref, 'refs/tags/v')
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Configure (branch)
if: "!startsWith(github.ref, 'refs/tags/v')"
run: cmake -B build -A Win32
- name: Build
run: cmake --build build --config Release
- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/v')"
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: build/Release/ClassicAPI.dll
+11 -4
View File
@@ -1,20 +1,26 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Version: 1.3
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
locales\locales.xml
GlobalStrings.lua
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
@@ -29,3 +35,4 @@ Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
-6
View File
@@ -1,6 +0,0 @@
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
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";
+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,
};
+5 -6
View File
@@ -83,12 +83,11 @@ function EventUtil.TriggerOnVariablesLoaded()
end
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
-- 1.12's IsAddOnLoaded returns a single bool. 3.3.5 split that into
-- (isLoadedOrLoading, isLoaded); treat truthy as "ready to use".
if IsAddOnLoaded(addOnName) then
callback()
return
end
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
if isLoaded then
callback();
return;
end
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
end
@@ -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
@@ -0,0 +1,3 @@
function GameTooltip_Hide()
GameTooltip:Hide()
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
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
FOCUSTARGET = "Ziel als Fokus festlegen";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
+25
View File
@@ -0,0 +1,25 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
COPPER_AMOUNT_SYMBOL = "c";
FOCUSTARGET = "Focus Target";
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";
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";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
FOCUSTARGET = "Establecer objetivo";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
TARGETFOCUS = "Fijar unidad objetivo";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
+9
View File
@@ -0,0 +1,9 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/..\FrameXML\UI.xsd">
<Script file="enUS.lua"/>
<Script file="deDE.lua"/>
<Script file="esES.lua"/>
<Script file="frFR.lua"/>
<Script file="ruRU.lua"/>
<Script file="zhCN.lua"/>
<Script file="zhTW.lua"/>
</Ui>
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
TARGETFOCUS = "Восстановить цель";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
+12
View File
@@ -0,0 +1,12 @@
if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
+217
View File
@@ -0,0 +1,217 @@
-- AoE Loot Test
--
-- /aoeloot opens a picker window listing every item in every lootable
-- corpse within click range. Clicking a row hands the (guid, itemID)
-- pair to C_Loot.LootUnitItem and re-scans so the picker refreshes
-- with whatever's left.
--
-- Known visual: each scan opens / closes loot windows in rapid
-- succession, so the Blizzard LootFrame flickers during the scan. This
-- addon intentionally doesn't suppress that — the goal is to exercise
-- the C++ surface, not to ship polished UI.
local MAX_ROWS = 24
local RESCAN_DELAY = 0.3 -- seconds; lets LootUnitItem complete before re-scan
-- ---------- UI scaffolding ----------
-- Frame geometry. Height is recomputed dynamically per scan so the
-- window only takes up as much screen real estate as the result count
-- needs — see `ResizeForRows`.
local FRAME_WIDTH = 320
local HEADER_HEIGHT = 64 -- title + status + insets; minimum frame height
local ROW_HEIGHT = 22
local frame = CreateFrame("Frame", "AoELootTestFrame", UIParent)
frame:SetWidth(FRAME_WIDTH)
frame:SetHeight(HEADER_HEIGHT)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
-- Vanilla 1.12 fires script handlers with the frame set as the global
-- `this`, not as a `self` arg to the function. Passing `frame.StartMoving`
-- directly (the modern shortcut) breaks because the C method has no
-- `self` to operate on. Wrap in closures so `this:method()` resolves
-- correctly inside the handler.
frame:SetScript("OnDragStart", function() this:StartMoving() end)
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
frame:Hide()
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", frame, "TOP", 0, -16)
title:SetText("AoE Loot")
local status = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -36)
status:SetText("")
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
-- ---------- Row pool ----------
local rows = {}
local function CreateRow(parent, idx)
local row = CreateFrame("Button", nil, parent)
row:SetWidth(290)
row:SetHeight(20)
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 15,
-54 - (idx - 1) * ROW_HEIGHT)
local icon = row:CreateTexture(nil, "ARTWORK")
icon:SetWidth(18); icon:SetHeight(18)
icon:SetPoint("LEFT", row, "LEFT", 0, 0)
row.icon = icon
local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
text:SetPoint("RIGHT", row, "RIGHT", 0, 0)
text:SetJustifyH("LEFT")
row.text = text
local hl = row:CreateTexture(nil, "HIGHLIGHT")
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
hl:SetBlendMode("ADD")
hl:SetAllPoints(row)
-- Tooltip on hover. The engine's native `SetHyperlink` does a
-- literal `SStrCmpI` of the input's first 5 chars against the
-- string `"item:"` at `.rdata` VA `0x00842D60` — anything that
-- starts with `|c...` (full link with color) or `|Hitem` (full
-- link without color) fails the prefix check with "Unknown link
-- type". Only the bare payload form survives.
--
-- We extract the payload from the engine's full link rather than
-- composing `item:ID:0:0:0` ourselves, so per-instance fields
-- (enchant / random-suffix / unique) survive round-trip and
-- "Stringy Wolf Meat of the Bear" tooltips correctly instead of
-- collapsing to the un-suffixed itemID.
row:SetScript("OnEnter", function()
if this.payload then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetHyperlink(this.payload)
GameTooltip:Show()
end
end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
row:Hide()
return row
end
for i = 1, MAX_ROWS do
rows[i] = CreateRow(frame, i)
end
-- ---------- Render ----------
-- Tail of a GUID string — last 4 hex chars of the low dword. Lets the
-- user tell apart two rows of "Stringy Wolf Meat x1" coming from
-- different corpses, without occupying a whole column with the full
-- 16-digit GUID.
local function ShortGuidSuffix(guid)
return "[.." .. string.sub(guid, -4) .. "]"
end
-- Extract the `item:ID:enchant:suffix:unique` payload out of the
-- engine's full link string (`|cff...|Hitem:...|h[Name]|h|r`).
-- Vanilla `SetHyperlink` requires the input to start literally with
-- `"item:"` — so we can't pass the full link, but we also can't drop
-- the per-instance fields (random suffix is what distinguishes "of
-- the Bear" from "of the Eagle" on otherwise identical itemIDs).
-- Returns the payload, or nil if extraction fails.
local function PayloadFromLink(link)
if not link then return nil end
return string.match(link, "|H(item:[^|]+)|h")
end
local function ClearRows()
for i = 1, MAX_ROWS do
rows[i]:Hide()
rows[i]:SetScript("OnClick", nil)
rows[i].payload = nil
end
end
local function PopulateRows(results)
local idx = 0
for _, corpse in ipairs(results) do
for _, item in ipairs(corpse.items) do
idx = idx + 1
if idx > MAX_ROWS then break end
local row = rows[idx]
local name, _, _, _, _, _, _, _, icon = GetItemInfo(item.itemID)
row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
local label = name or ("Item #" .. item.itemID)
if item.count and item.count > 1 then
label = label .. " x" .. item.count
end
label = label .. " " .. ShortGuidSuffix(corpse.guid)
row.text:SetText(label)
row.payload = PayloadFromLink(item.link) -- for OnEnter tooltip
local guid, itemID = corpse.guid, item.itemID
row:SetScript("OnClick", function()
C_Loot.LootUnitItem(guid, itemID)
C_Timer.After(RESCAN_DELAY, function()
if frame:IsShown() then C_Loot.ScanNearbyLoot() end
end)
end)
row:Show()
end
if idx > MAX_ROWS then break end
end
for i = idx + 1, MAX_ROWS do rows[i]:Hide() end
local shown
if idx == 0 then
status:SetText("Nothing lootable in range.")
shown = 0
elseif idx > MAX_ROWS then
status:SetText(MAX_ROWS .. "+ items (showing first " .. MAX_ROWS .. ")")
shown = MAX_ROWS
else
status:SetText(idx .. " item(s) available")
shown = idx
end
frame:SetHeight(HEADER_HEIGHT + shown * ROW_HEIGHT)
end
-- ---------- Scan trigger ----------
local function StartScan()
status:SetText("Scanning...")
ClearRows()
frame:SetHeight(HEADER_HEIGHT) -- collapse while we wait for results
if not C_Loot.ScanNearbyLoot() then
status:SetText("Scan busy or not in world.")
end
end
-- ---------- Event wiring ----------
local evt = CreateFrame("Frame")
evt:RegisterEvent("LOOT_SCAN_COMPLETED")
evt:SetScript("OnEvent", function()
-- Race guard: a delayed scan may complete after the user closed
-- the picker. Just drop the result rather than re-showing.
if not frame:IsShown() then return end
PopulateRows(C_Loot.GetLastScanResults())
end)
-- ---------- Slash command ----------
RegisterNewSlashCommand(function()
if frame:IsShown() then
StartScan() -- already open → just refresh
else
frame:Show()
StartScan()
end
end, "aoeloot", "aoe")
+7
View File
@@ -0,0 +1,7 @@
## Interface: 11200
## Title: AoE Loot Test
## Notes: Test bed for ClassicAPI's C_Loot.ScanNearbyLoot / LootUnitItem. /aoeloot
## Dependencies: !!!ClassicAPI
## Version: DEV
AoELootTest.lua
+21 -1
View File
@@ -47,9 +47,29 @@ add_custom_command(
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
VERBATIM)
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
# into a generated header. `src/bindings/Inject.cpp` extracts the
# inner `<Binding>` elements from the embedded buffer and splices them
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
add_custom_command(
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
-DSYMBOL=kEmbeddedBindingsXml
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
DEPENDS
"${EMBEDDED_BINDINGS_SRC}"
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
VERBATIM)
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
${EMBEDDED_CLASSICAPI_HEADER})
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
+51 -13
View File
@@ -18,40 +18,46 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [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` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
| [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` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [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:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [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`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [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.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` |
| [Quest](docs/API.md#quest) | `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.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` |
| [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` |
| [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`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [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
@@ -71,6 +77,7 @@ the glue state because GlueXML had no way to reach them otherwise.
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
@@ -84,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 |
@@ -97,6 +116,7 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
@@ -131,16 +151,34 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
### Bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
| Binding | Action |
|---------|--------|
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
## 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)
+62
View File
@@ -0,0 +1,62 @@
# embed_file.cmake — bundle a single file into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
# scope; the header is meant to be included exactly once.
#
# Usage:
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
if(NOT INPUT_FILE)
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
endif()
if(NOT SYMBOL)
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
endif()
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per
# line keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
// Source: ${INPUT_FILE}
#pragma once
#include <cstddef>
static constexpr unsigned char ${SYMBOL}[] = {
${BYTES}};
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
+2408 -818
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -3,7 +3,9 @@
Catalogue of the Lua C API functions linked into the client. Vanilla WoW is
built against **Lua 5.0** (not 5.1), and the entire API plus the auxiliary
library lives in a contiguous block of `.text` from roughly `0x006F2000` to
`0x006F5100`. ~120 functions; the public C API is the core of that.
`0x006F5100`. ~120 functions; the public C API is the core of that. The
thread / coroutine machinery (`lua_resume`, `lua_yield`, the inner
`luaE_newthread`) lives slightly higher at `0x006F6000`–`0x006F6900`.
**Cross-verified against two binaries:**
@@ -75,6 +77,8 @@ context but not byte-by-byte verified.
| VA | Function | Conv. | Notes | Conf |
|--------------|-------------------------|----------|--------------------------------------------------------------|------|
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
| `0x006F3070` | `lua_gettop` | fastcall | `(top - base) >> 4` | ★ |
| `0x006F3080` | `lua_settop` | fastcall | | ✓ |
| `0x006F30D0` | `lua_remove` | fastcall | shift-down loop + decr_top; previously misidentified as `lua_pushvalue` | ★ |
@@ -155,7 +159,10 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4290` | *internal CFunction trampoline* | | called via `lua_cpcall` | ★ |
| `0x006F4320` | `lua_load` | fastcall | reader / loader entry | ★ |
| `0x006F4370` | `lua_dump` | fastcall | requires tag 6 + `Closure.isC == 0` | ★ |
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
| `0x006F6B10` | *internal* `luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
### Misc / GC / version
+61 -6
View File
@@ -48,7 +48,15 @@ namespace Lua {
F(PCall, lua_pcall, LUA_PCALL) \
F(Next, lua_next, LUA_NEXT) \
F(Type, lua_type, LUA_TYPE) \
F(Error, lua_error, LUA_ERROR)
F(Error, lua_error, LUA_ERROR) \
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
F(XMove, lua_xmove, LUA_X_MOVE) \
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
@@ -186,20 +194,67 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
}
}
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
// the string at the top of the stack with the substitution result.
// Discards gsub's second return (the substitution count) by
// requesting only one return from `Call`.
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
PushString(L, "string");
GetTable(L, GLOBALS_INDEX);
PushString(L, "gsub");
GetTable(L, -2); // [..., str, string, gsub]
Remove(L, -2); // [..., str, gsub]
Insert(L, -2); // [..., gsub, str]
PushString(L, pattern);
PushString(L, replacement);
Call(L, 3, 1); // [..., result]
}
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n) {
// Resolve `string.format` from _G. The engine ships it as both a
// global and as `string.format`; the global is faster (one
// gettable instead of two) and equally available across builds.
// Resolve `string.format` from _G.
PushString(L, "format");
GetTable(L, GLOBALS_INDEX);
PushLocalizedString(L, globalName, fallback);
// Expand WoW's `|4singular:plural;` (and 3-form
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
// grammar (Slavic counting has a separate genitive-singular form
// for 2..4); we collapse to nominative/plural since vanilla has
// no proper locale-aware counting rules. 3-form pattern runs
// first because it's more specific. Vanilla's `string.format`
// doesn't process `|4` natively, so without this the template
// would render literally.
const char *picker3 = (n == 1) ? "%1" : "%3";
const char *picker2 = (n == 1) ? "%1" : "%2";
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
PushNumber(L, static_cast<double>(n));
Call(L, 2, 1); // format(fmt, n) → string
Call(L, 2, 1); // format(expandedFmt, n) → string
}
} // 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;
+75 -1
View File
@@ -49,6 +49,8 @@ constexpr int TYPE_NUMBER = 3;
constexpr int TYPE_STRING = 4;
constexpr int TYPE_TABLE = 5;
constexpr int TYPE_FUNCTION = 6;
constexpr int TYPE_USERDATA = 7;
constexpr int TYPE_THREAD = 8;
using lua_isnumber_t = bool(__fastcall *)(void *L, int index);
using lua_isstring_t = bool(__fastcall *)(void *L, int index);
@@ -76,7 +78,27 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
using lua_next_t = int(__fastcall *)(void *L, int idx);
using lua_type_t = int(__fastcall *)(void *L, int index);
using lua_error_t = void(__cdecl *)(void *L, const char *);
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
// and throws. Existing callers pass a single literal (no `%`), which
// is fine because cdecl tolerates extra-arg-less calls. New callers
// forwarding user-supplied error strings should use `Error(L, "%s",
// userMsg)` to avoid format-string interpretation of `%` in the
// message body.
using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
// infers `void` because the inner call's value just flows through.
// We declare the return type honestly here; callers that don't want
// to rely on the calling convention preserving eax can read the
// thread back from L's top via `ToPointer(L, -1)`.
using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -110,6 +132,14 @@ extern const lua_pcall_t PCall;
extern const lua_next_t Next;
extern const lua_type_t Type;
extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
extern const lua_iscfunction_t IsCFunction;
extern const lua_xmove_t XMove;
extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
@@ -212,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`
+913 -14
View File
File diff suppressed because it is too large Load Diff
+57 -1
View File
@@ -40,13 +40,14 @@
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
// reads each `.lua` file through the same hooked read path —
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
#include "Game.h"
#include "Offsets.h"
#include "bindings/Inject.h"
#include <cstdint>
#include <cstdio>
@@ -248,6 +249,14 @@ void DecideSource() {
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2) {
// FrameXML Bindings.xml gets an inline splice for our TARGETING
// additions — see `bindings/Inject.cpp`. Returns true with the
// patched buffer ready for the engine's binding parser to consume.
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
return 1;
}
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
@@ -301,11 +310,58 @@ using AddonInit_t = void(__fastcall *)(const char *accountName);
AddonInit_t AddonInit_o = nullptr;
using TocParser_t = void(__fastcall *)(const char *name);
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
int position, int anchor);
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
// registry's linked list, and the engine's addon-load pass at
// `FUN_0051F600` walks that list in *insertion order* (NOT
// alphabetical order — the alphabetical sort only applies to the
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
// injection lands at the very end of the load order, after every
// disk-scanned addon. Addons that consume our globals from their
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
// hard-error during their boot.
//
// Fix: walk the linked list, find our entry by name, and re-link
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
void MoveEmbeddedToListHead() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
}
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
}
const Game::HookAutoRegister _hookFileRead{
+51 -4
View File
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
// For numeric input, returns the engine's canonical casing. For
// string input, returns the input verbatim once existence is
// confirmed.
// confirmed; nil for an unregistered name.
//
// String-input existence isn't free: `ResolveAddOnName` echoes the
// string for the field accessors below (`GetAddOnTitle`, etc.) which
// short-circuit cleanly on unknown names via the engine's own NULL-
// return paths. `GetAddOnName` doesn't call any engine accessor, so
// it'd otherwise echo any string. Probe the registry explicitly.
int __fastcall Script_GetAddOnName(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
return 1;
}
@@ -163,9 +174,13 @@ int __fastcall Script_IsAddOnLoadable(void *L) {
return 2;
}
auto isLoD = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOAD_ON_DEMAND);
if (isLoD(entry) != 0) {
// Already-loaded addons are trivially loadable — skip the
// recursive dep walk and return (true, nil) directly. `IS_LOADED`
// reads the byte at `entry+0x18` (the loader sets that to 1 on
// successful load).
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(entry) != 0) {
Game::Lua::PushBoolean(L, 1);
Game::Lua::PushNil(L);
return 2;
@@ -229,6 +244,36 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
return 1;
}
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
//
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
// matters for LoD addons whose load is split across multiple
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
// inside a single call, so the "loading" state is never observable
// from Lua. We return the same boolean twice — `loadedOrLoading`
// and `loaded` are identical here.
//
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
// string names. `IS_LOADED` accepts an entry pointer as its name arg
// because the entry's first 12 bytes are the inline NUL-terminated
// directory name (same as the other per-field accessors), so we can
// feed it either an entry or a Lua-side string transparently.
int __fastcall Script_IsAddOnLoaded(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
const bool loaded = isLoaded(entry) != 0;
Game::Lua::PushBool(L, loaded);
Game::Lua::PushBool(L, loaded);
return 2;
}
// `C_AddOns.DoesAddOnExist(indexOrName)` — true iff the engine's
// addon registry has a matching entry. For numeric input we trust
// `GetByIndex`'s OOR-NULL; for string input we walk the flat array,
@@ -276,6 +321,8 @@ static void RegisterLuaFunctions() {
&Script_GetAddOnSecurity);
Game::Lua::RegisterTableFunction("C_AddOns", "DoesAddOnExist",
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+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;
}
+98 -15
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);
@@ -110,6 +120,13 @@ const uint8_t *FindPlayerBuffEntry(uint32_t spellID) {
// what Lua's `GetTime()` returns (engineMs * 0.001), so converting
// `expirationMs * 0.001` lands directly on the same timeline addons
// compare against on the Lua side. No epoch reconciliation needed.
//
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
// days of system uptime. A signed read flips negative past that
// point, which broke a `<= 0` early-bail that was supposed to catch
// only the "no timing" (0) sentinel. The engine's own arithmetic
// works on the unsigned tick because both sides of `expiration -
// currentMs` wrap together.
double PlayerBuffExpirationSeconds(const uint8_t *entry) {
if (entry == nullptr)
return 0.0;
@@ -117,12 +134,12 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
if (slotCode < 0)
return 0.0;
auto *expirationTable = reinterpret_cast<const int *>(
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const int expirationMs = expirationTable[slotCode];
if (expirationMs <= 0)
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
return static_cast<double>(static_cast<uint32_t>(expirationMs)) * 0.001;
return static_cast<double>(expirationMs) * 0.001;
}
// Computes the spell's base duration in seconds via Spell.dbc →
@@ -212,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)
@@ -225,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;
}
@@ -232,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)
@@ -244,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)
@@ -264,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;
}
@@ -367,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);
@@ -384,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
+20
View File
@@ -0,0 +1,20 @@
<!-- Spliced into the engine's Interface\FrameXML\Bindings.xml at
load time by src/bindings/Inject.cpp. The `header` attribute on
the root tag tells the splicer which engine section to extend —
it picks the appropriate insertion point so the bindings appear
at the tail of that group in the keybind UI. The engine's own
XML parser ignores attributes on the root tag, so this is
metadata for the splicer only. -->
<Bindings header="TARGETING">
<Binding name="FOCUSTARGET">
FocusUnit("target");
</Binding>
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
</Binding>
</Bindings>
+297
View File
@@ -0,0 +1,297 @@
// 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/>.
// Inject ClassicAPI-owned bindings into a chosen engine section.
//
// Why a file-read hook rather than an addon-side Bindings.xml: vanilla
// stores every binding in a single linearly-indexed table, and the
// keybind UI walks that table in index order with `HEADER_*` pseudo-
// entries splitting groups. Addon Bindings.xml files get parsed
// AFTER FrameXML, so even with `header="TARGETING"` an addon binding
// receives a display index higher than every engine binding — it
// renders orphaned at the very bottom of the keybind list rather than
// under TARGETING.
//
// To land inside a specific section, the binding's display index has
// to fall between that section's `HEADER_*` entry and the next one.
// The cleanest way to arrange that is to splice the bindings into the
// FrameXML file the engine itself parses, so they take their indices
// from the same running counter every native binding does.
//
// What gets spliced is driven by `src/bindings/Bindings.xml`. The
// root `<Bindings header="X">` tag names the engine section to extend
// (e.g. `"TARGETING"`); the inner `<Binding>` children are spliced
// verbatim. The splicer locates `header="X"` in the engine file
// (which appears on the first binding of section X), then the next
// `header="..."` after that (start of the following section), and
// inserts our content just before that next-section marker — so our
// entries land at the tail of section X. If X is the last section in
// the file, the splice goes just before `</Bindings>`.
//
// The engine's binding parser is `FUN_004b6f70`. It calls
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
// elements assigning each the next display index from a running
// counter. By the time our injected bindings are processed, the
// engine has already emitted the section's `HEADER_*` and a sequence
// of native entries; ours append to that sequence and the next
// section's header follows.
#include "Inject.h"
#include "Offsets.h"
#include "embedded_bindings.h"
#include <cstdio>
#include <cstring>
namespace Bindings::Inject {
namespace {
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
// Parsed view of `src/bindings/Bindings.xml`:
// - `header` is the value of `header="..."` on the root `<Bindings>`
// tag. Tells the splicer which engine section to extend.
// - `innerData / innerSize` point at the bytes between the
// `<Bindings ...>` opening and `</Bindings>` closing tags.
//
// All-zero fields mean "embedded buffer is malformed" — defensive,
// shouldn't happen since the source is build-time-validated. Splice
// is skipped (engine sees its original Bindings.xml unchanged).
struct EmbeddedConfig {
char header[64];
const char *innerData;
size_t innerSize;
};
// Advances `p` past any `<!-- ... -->` comment block starting at the
// current position. Returns the new position (past the closing `-->`)
// if a comment was skipped, or `p` unchanged if no comment was at
// the current position. Caps at `end` if the comment is unterminated.
const char *SkipCommentIfAny(const char *p, const char *end) {
if (p + 4 > end) return p;
if (std::memcmp(p, "<!--", 4) != 0) return p;
p += 4;
while (p + 3 <= end) {
if (std::memcmp(p, "-->", 3) == 0)
return p + 3;
++p;
}
return end;
}
// Linear scan for `marker` in `[start, end)`, skipping any `<!--...-->`
// comment blocks (where unrelated occurrences of XML tags might live).
const char *FindOutsideComment(const char *start, const char *end, const char *marker) {
const size_t markerLen = std::strlen(marker);
const char *p = start;
while (p + markerLen <= end) {
const char *afterComment = SkipCommentIfAny(p, end);
if (afterComment != p) {
p = afterComment;
continue;
}
if (std::memcmp(p, marker, markerLen) == 0)
return p;
++p;
}
return nullptr;
}
// Parses `header="X"` out of the byte range `[tagStart, tagEnd)` (the
// opening `<Bindings ...>` tag). Copies the value into `out` (size
// `outSize`) and returns true on success.
bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
char *out, size_t outSize) {
static constexpr const char kAttr[] = "header=\"";
constexpr size_t kAttrLen = sizeof(kAttr) - 1;
for (const char *p = tagStart; p + kAttrLen < tagEnd; ++p) {
if (std::memcmp(p, kAttr, kAttrLen) != 0) continue;
const char *valStart = p + kAttrLen;
const char *valEnd = valStart;
while (valEnd < tagEnd && *valEnd != '"') ++valEnd;
const size_t len = static_cast<size_t>(valEnd - valStart);
if (len == 0 || len + 1 > outSize) return false;
std::memcpy(out, valStart, len);
out[len] = '\0';
return true;
}
return false;
}
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
const char *end = content + kEmbeddedBindingsXmlSize;
// Locate `<Bindings` opening tag (skipping any comment blocks
// that mention the tag literally for documentation purposes).
const char *open = FindOutsideComment(content, end, "<Bindings");
if (open == nullptr) return out;
// Find the closing `>` of the opening tag.
const char *tagEnd = open;
while (tagEnd < end && *tagEnd != '>') ++tagEnd;
if (tagEnd == end) return out;
// Pull out the `header="..."` attribute. Required — this tells
// the splicer which engine section to extend.
if (!ExtractHeaderAttr(open, tagEnd, out.header, sizeof(out.header)))
return out;
const char *innerStart = tagEnd + 1;
const char *close = FindOutsideComment(innerStart, end, "</Bindings>");
if (close == nullptr || close < innerStart) {
out.header[0] = '\0';
return out;
}
out.innerData = innerStart;
out.innerSize = static_cast<size_t>(close - innerStart);
return out;
}
// Computes the byte offset in the engine's Bindings.xml buffer where
// our entries should be spliced — i.e., the tail of the section
// identified by `headerName`. Strategy:
//
// 1. Find `header="<HEADER>"` in the engine's buffer (skipping
// comments). This is the attribute the engine itself uses on
// the FIRST binding of each section, so this finds where that
// section *starts*.
// 2. Find the NEXT `header="` after that. That marks the start of
// the FOLLOWING section.
// 3. Back up from that next-header position to the `<` of the
// `<Binding` tag containing it — that's our splice point.
// 4. If there's no next header (last section in the file), splice
// just before `</Bindings>`.
//
// Returns `nullptr` if step 1 fails (header missing from engine file
// — e.g., a heavily-modified FrameXML). Caller falls back to passing
// the original buffer through unchanged.
const char *FindSplicePosition(const char *content, size_t size,
const char *headerName) {
const char *end = content + size;
char headerNeedle[80];
std::snprintf(headerNeedle, sizeof(headerNeedle),
"header=\"%s\"", headerName);
const char *sectionStart = FindOutsideComment(content, end, headerNeedle);
if (sectionStart == nullptr) return nullptr;
// Walk past the matched attribute, then look for the next
// `header="` anywhere later in the file.
const char *afterCurrent = sectionStart + std::strlen(headerNeedle);
const char *nextHeader = FindOutsideComment(afterCurrent, end, "header=\"");
if (nextHeader != nullptr) {
// Back up to the `<` of the `<Binding` tag that owns this
// next-header attribute — that `<` is where the next section
// visually starts, and we splice immediately before it.
const char *p = nextHeader;
while (p > content && *p != '<') --p;
return p;
}
// Last section in the file — splice just before `</Bindings>`.
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
// requested `extraBytes` honored — the original allocates a Storm
// buffer of `fileSize + extraBytes` and zero-fills the tail.
void *origBuf = nullptr;
size_t origSize = 0;
const int readOk = original(unused, path, &origBuf, &origSize,
extraBytes, flag1, flag2);
if (readOk == 0 || origBuf == nullptr)
return false;
static const EmbeddedConfig config = ResolveEmbeddedConfig();
if (config.innerData == nullptr || config.innerSize == 0 ||
config.header[0] == '\0') {
// Embedded XML is missing the `<Bindings header="...">` root
// or the `</Bindings>` close tag. Pass the original through.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const char *content = static_cast<const char *>(origBuf);
const char *splice = FindSplicePosition(content, origSize, config.header);
if (splice == nullptr) {
// Requested section isn't present in this engine file (heavily
// modified FrameXML or different build). Pass through unchanged
// rather than risking corruption.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
const size_t splicePos = static_cast<size_t>(splice - content);
const size_t newSize = origSize + config.innerSize;
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
if (newBuf == nullptr) {
// Allocation failed — fall back to the original buffer so the
// engine still loads its own bindings.
if (outBuf != nullptr) *outBuf = origBuf;
if (outSize != nullptr) *outSize = origSize;
return true;
}
auto *dest = static_cast<char *>(newBuf);
std::memcpy(dest, content, splicePos);
std::memcpy(dest + splicePos, config.innerData, config.innerSize);
std::memcpy(dest + splicePos + config.innerSize,
content + splicePos, origSize - splicePos);
if (extraBytes > 0)
std::memset(dest + newSize, 0, extraBytes);
SMemFree(origBuf, __FILE__, __LINE__, 0);
if (outBuf != nullptr) *outBuf = newBuf;
if (outSize != nullptr) *outSize = newSize;
return true;
}
} // namespace Bindings::Inject
+37
View File
@@ -0,0 +1,37 @@
// 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 <cstddef>
namespace Bindings::Inject {
// `FUN_FILE_READ` shape — Storm-style: __stdcall, returns 1 on success
// with the buffer allocated via SMemAlloc into `*outBuf` (size in
// `*outSize`, plus `extraBytes` of trailing zero padding).
using FileReadFn = int(__stdcall *)(int unused, const char *path,
void **outBuf, size_t *outSize,
size_t extraBytes,
int flag1, int flag2);
// Path-matches `Interface\FrameXML\Bindings.xml`. On match: fetches
// the real file via `original`, splices our ClassicAPI bindings into
// the TARGETING block, and returns `true` with `*outBuf` / `*outSize`
// set to a fresh Storm buffer the caller can SMemFree normally.
// Returns `false` for any other path — caller should fall through to
// its own logic.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original);
} // namespace Bindings::Inject
+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
+216
View File
@@ -0,0 +1,216 @@
// 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/>.
//
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
// type tag 8) but the `coroutine` global table was never registered.
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
// "Cross-binary reference" technique in CLAUDE.md).
#include "Game.h"
#include <cstdint>
namespace Coroutine {
// Lua 5.0 result codes returned by `lua_resume`.
static constexpr int LUA_OK = 0;
static constexpr int LUA_YIELD = 1;
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
// *(L+0x14) = *(L+0x28)
// *(L+0x0C) = **(L+0x28)
static constexpr uintptr_t OFF_L_CI = 0x14;
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
// the coroutine's body function is mid-execution (paused at a yield);
// when equal, no Lua function is on this thread's call stack.
static void *LuaCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
}
static void *LuaBaseCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
}
// Coroutine status codes returned by `CoStatus` below — fed into the
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
// also lets us label "unsuspended" states for resume's error path.
static constexpr int CO_RUNNING = 0;
static constexpr int CO_SUSPENDED = 1;
static constexpr int CO_NORMAL = 2;
static constexpr int CO_DEAD = 3;
static const char *const kStatusNames[] = {
"running",
"suspended",
"normal",
"dead",
};
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
// lbaselib.c, but we can't rely on `L->status` here: this build's
// `lua_yield` doesn't write status (it sets a CI state flag bit
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
// check fails. We use the `ci > base_ci` signal instead — true iff
// a Lua function is currently paused on this thread's call stack.
//
// State map:
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
// never resumed)
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
// co's stack empty; the ci comparison is what distinguishes them.
// "Normal" (coroutine that resumed a deeper coroutine — appears as
// the parent in the call chain) is rare and would require walking
// the global thread list; we collapse it into "suspended".
static int CoStatus(void *L, void *co) {
if (co == L)
return CO_RUNNING;
if (LuaCI(co) > LuaBaseCI(co))
return CO_SUSPENDED;
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
}
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
// (with values already xmove'd onto L), or -1 on error (with the
// error message left on L's stack).
//
// WoW's modified Lua doesn't follow the standard `lua_resume` status
// contract: it returns `0` for normal completion and `1` for both
// "yielded" AND "errored mid-execution". We disambiguate the two
// `s==1` cases by checking the call-stack depth — a yielded
// coroutine still has its body paused (`ci > base_ci`), while an
// errored coroutine has been unwound back to `base_ci`. Errors hit
// from the "dead/already-resumed" early-out (e.g. the inner
// `cannot resume dead coroutine` path) also land in `s != 0` with
// `ci == base_ci`, so the same branch covers them.
static int AuxResume(void *L, void *co, int nargs) {
const int st = CoStatus(L, co);
if (st != CO_SUSPENDED) {
Game::Lua::PushString(L, st == CO_DEAD
? "cannot resume dead coroutine"
: "cannot resume non-suspended coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
Game::Lua::XMove(co, L, nres);
return nres;
}
// Error path: engine left a single error message on co's stack.
Game::Lua::XMove(co, L, 1);
return -1;
}
static int __fastcall Script_Coroutine_Create(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_FUNCTION || Game::Lua::IsCFunction(L, 1)) {
Game::Lua::ArgError(L, 1, "Lua function expected");
return 0; // unreachable
}
Game::Lua::NewThread(L); // [arg1, NL]
void *NL = Game::Lua::ToThread(L, -1);
if (NL == nullptr) {
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
return 0; // unreachable
}
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
return 1;
}
static int __fastcall Script_Coroutine_Yield(void *L) {
return Game::Lua::Yield(L, Game::Lua::GetTop(L));
}
static int __fastcall Script_Coroutine_Resume(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
const int nargs = Game::Lua::GetTop(L) - 1;
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// Error message is on top of L's stack. Push `false` and
// shuffle it below the message: [..., msg] -> [..., false, msg].
Game::Lua::PushBool(L, false);
Game::Lua::Insert(L, -2);
return 2;
}
// Success: `r` values on top of L's stack. Push `true` and slide
// it into position so returns are [..., true, v1, v2, ..., vr].
Game::Lua::PushBool(L, true);
Game::Lua::Insert(L, -(r + 1));
return r + 1;
}
static int __fastcall Script_Coroutine_Status(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
return 1;
}
// `coroutine.wrap`'s returned closure. The captured coroutine lives
// in upvalue 1; each invocation forwards args via resume and either
// returns the yielded/returned values directly (no { ok, ... }
// wrapping like resume does) or re-raises on error.
static int __fastcall WrapAux(void *L) {
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
if (co == nullptr) {
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
return 0; // unreachable
}
const int nargs = Game::Lua::GetTop(L);
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
// a printf-format string + va args; we pass `%s` so any `%`
// characters in the message are treated as literal.
const char *msg = Game::Lua::ToString(L, -1);
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
return 0; // unreachable
}
return r;
}
static int __fastcall Script_Coroutine_Wrap(void *L) {
// Reuse cocreate to build the thread (validates arg, allocates,
// moves fn into the new state). Leaves the thread on top of L.
Script_Coroutine_Create(L);
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Coroutine
+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
+151 -16
View File
@@ -35,6 +35,7 @@
#include "Offsets.h"
#include "Set.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
@@ -330,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
// would race with). Reads the per-CGItem instance flag at
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
// and the engine's own pickup/unlock paths manipulate.
//
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
// for items the player has deleted/mailed/traded away, the engine's
// CGItem cache often retains a stale entry that `ResolveByGUID`
// still hands back, but the inventory walk no longer finds the GUID
// anywhere. Reading the lock flag off the stale CGItem reflects
// whatever bits were last set on it (frequently the "locked"
// in-transaction state from when it was being deleted/swapped),
// which would falsely report the set as "contains locked items"
// and prevent `UseEquipmentSet` from running through addon
// wrappers that gate on this check. The inventory presence check
// matches modern semantics: a missing item is *missing*, not
// *locked*.
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
@@ -340,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (Locations::FindGUID(g) == 0)
continue; // not in inventory — treat as missing, not locked
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
@@ -401,39 +417,158 @@ int __fastcall Script_UseEquipmentSet(void *L) {
uint64_t guids[SLOT_COUNT];
std::memcpy(guids, s->items, sizeof(guids));
// Snapshot empty bag slots up front. The CGItem state doesn't
// update between packet sends (server has to acknowledge each
// swap), so iterating the bag walk fresh per unequip would
// repeatedly pick the same "first empty" slot — second packet
// collides with what packet 1 just put there, and the server
// tries to swap that item back onto the wrong paperdoll slot
// → "This item cannot be equipped".
struct FreeSlot {
int bag;
int slot;
};
FreeSlot freeSlots[64];
int freeSlotCount = 0;
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
const int slots = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
freeSlots[freeSlotCount++] = {bag, slot};
}
}
int nextFreeSlot = 0;
// Pre-snapshot each set item's location against the ORIGINAL
// state, before any swap packets fly. This mirrors how 4.3.4's
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
// resolve all 19 source locations up front, then execute. The
// engine's CGItem / InvMgr state lags behind our packet sends
// (waits for server ACK), so any `FindGUID` mid-execution would
// return stale answers and we'd re-resolve items into the wrong
// place.
int sourceLoc[SLOT_COUNT];
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED) {
sourceLoc[i] = 0;
continue;
}
sourceLoc[i] = Locations::FindGUID(g);
}
// Virtual paperdoll occupant model — seeded from the engine's
// initial paperdoll GUID array, then updated in-place after each
// swap so phase 1 can chain rotations and phase 3 can spot
// already-empty slots.
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Locates the current virtual slot of a GUID on the paperdoll,
// or 0 if it's not currently there. Used during phase 1 so
// chained paperdoll-to-paperdoll moves operate on the *current*
// virtual position, not the stale pre-snapshot.
auto currentPaperdollSlot = [&](uint64_t g) -> int {
if (g == 0)
return 0;
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
if (virtualPaperdoll[slot - 1] == g)
return slot;
}
return 0;
};
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
// Move items already on the paperdoll into their target slots.
// Source and destination are both paperdoll, so atomic swaps
// never touch bag state — phase 2's bag lookups stay accurate.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
const int loc = Locations::FindGUID(g);
const int loc = sourceLoc[i];
if (loc == 0)
continue; // missing — nothing to do
// Already in its target slot? Skip.
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
(loc & LOC_SLOT_MASK) == targetSlot)
continue;
// In bank (main or bank-bag) — server rejects equip-from-bank.
if (loc & LOC_BANK)
continue;
continue; // server rejects equip-from-bank
if (loc & LOC_BAGS)
continue; // phase 2
const int srcSlot = currentPaperdollSlot(g);
if (srcSlot == 0 || srcSlot == targetSlot)
continue; // missing or already there
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
if (loc & LOC_BAGS) {
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
} else {
const int srcSlot = loc & LOC_SLOT_MASK;
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
virtualPaperdoll[srcSlot - 1] = displaced;
}
}
// ── Phase 2: bag → paperdoll ───────────────────────────────────
// Move items from bag slots onto the paperdoll. Each atomic swap
// displaces the current paperdoll occupant into the bag slot the
// incoming item vacated. Anything we still wanted on the paperdoll
// was already placed by phase 1, so phase 2 displacements can't
// strand a future set item in a bag.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (virtualPaperdoll[targetSlot - 1] == g)
continue; // already at target (probably by phase 1)
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue;
if ((loc & LOC_BAGS) == 0)
continue; // not from a bag — phase 1 handled, or item missing
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
// The displaced item lands in (srcBag, srcSlot). We
// don't track bag-side virtual state — by phase 3 we
// only care about the paperdoll occupant.
}
}
// ── Phase 3: unequip slots the set wants empty ─────────────────
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
// (if any) to a pre-snapshotted free bag slot. Runs last so any
// displaced items from phases 1/2 that landed in the slot get
// unequipped here too.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
if (guids[i] != GUID_EMPTY)
continue;
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
if (occupant == 0)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
if (equipped == nullptr)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
-2
View File
@@ -140,8 +140,6 @@ uint32_t Create(const char *name, const char *icon) {
EnsureLoaded();
if (g_path.empty() || name == nullptr || name[0] == '\0')
return 0;
if (g_sets.size() >= MAX_SETS)
return 0;
if (FindByName(name) != nullptr)
return 0;
Set s;
+11
View File
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
return Item::Location::ResolveByGUID(guid);
}
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = guids[i];
}
} // namespace EquipmentSet::Locations
+12
View File
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
// isn't known to the client. Side-effect free.
const uint8_t *ResolveItemByGUID(uint64_t guid);
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
// Empty / unequipped slots write 0. Reads from the player invMgr's
// flat GUID array directly — no Lua stack, no engine state changes.
//
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
// stays current across in-flight swaps. The engine's CGItem state
// lags behind packet sends (server has to ACK each swap), so any
// subsequent `FindGUID` after the first packet would return stale
// pre-swap locations. Tracking moves locally bypasses that lag.
void SnapshotPaperdoll(uint64_t *out);
} // namespace EquipmentSet::Locations
-5
View File
@@ -56,11 +56,6 @@ inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
// (1.15.x is also 32). 4.3.4 used the same buffer size.
constexpr int MAX_NAME_LEN = 31;
// Hard cap matching 4.3.4 behavior. Modern Classic Era (1.15) raised
// this to 30; we follow the 4.3.4 number so legacy addons that show
// "X / 10" don't surprise users.
constexpr int MAX_SETS = 10;
// `GetItemLocations` encoding — matches Blizzard FrameXML's
// `EquipmentManager_UnpackLocation` (constants are in
// `Constants.lua`: `ITEM_INVENTORY_LOCATION_*` and
+17
View File
@@ -105,6 +105,23 @@ int Lookup(const char *name) {
return -1;
}
int LookupByName(const char *name) {
if (name == nullptr)
return -1;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = 0; i < count; ++i) {
const char *entryName = *reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
return i;
}
return -1;
}
void RetryClaims() {
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
+30
View File
@@ -47,8 +47,22 @@ struct AutoReserve {
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
int LookupByName(const char *name);
// Dispatches a custom event via the engine's printf-style event
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
@@ -74,6 +88,22 @@ inline void Fire(int eventID, const char *format, Args... args) {
fn(eventID, format, args...);
}
// Convenience for the modern WoW `(id: number, success: bool)` event
// shape (ITEM_DATA_LOAD_RESULT, GET_ITEM_INFO_RECEIVED,
// QUEST_DATA_LOAD_RESULT, …). The engine's printf-style dispatcher has
// no `%b`, so a naive `%d%d` with `0` for failure surfaces `arg2 = 0` —
// truthy in Lua, breaking the canonical `if success then` branch. We
// push `1` for success and `nil` for failure instead, leaning on the
// engine's `lua_pushstring(L, NULL) → lua_pushnil` tail-jump so the
// dispatcher itself emits the nil without any pre-staging. Lua handlers
// can then use the natural idiom: nil is falsy, `1` is truthy.
inline void FireIdSuccess(int eventID, int id, bool success) {
if (success)
Fire(eventID, "%d%d", id, 1);
else
Fire(eventID, "%d%s", id, static_cast<const char *>(nullptr));
}
// Internal: try to claim a slot for every reservation that's still
// unclaimed (`slot < 0`). Called from the `Frame::RegisterEvent` hook
// in DllMain — every time Lua calls `frame:RegisterEvent(...)`, we
+2 -28
View File
@@ -12,36 +12,10 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
#include "event/Custom.h"
namespace Event::Util {
// Walks the same `EventEntry` array that `Frame::RegisterEvent` strcmps
// against (base at `[VAR_EVENT_TABLE_BASE_PTR]`, count at
// `[VAR_EVENT_TABLE_COUNT]`, stride 0x10, name field at +0x00). This is
// the source of truth for "events the engine will accept registration
// for", so it picks up anything we've injected via `Event::Custom::Register`
// in addition to all built-in events.
static bool IsKnownEventName(const char *needle) {
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return false;
for (int i = 0; i < count; i++) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (std::strcmp(name, needle) == 0)
return true;
}
return false;
}
static int __fastcall Script_IsEventValid(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: C_EventUtils.IsEventValid(eventName)");
@@ -52,7 +26,7 @@ static int __fastcall Script_IsEventValid(void *L) {
Game::Lua::PushBool(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, IsKnownEventName(needle));
Game::Lua::PushBoolean(L, Event::Custom::LookupByName(needle) >= 0);
return 1;
}
+248
View File
@@ -0,0 +1,248 @@
// 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/>.
// Restores `UNIT_FACTION("player")` event semantics that vanilla 1.12.1
// silently dropped from four engine paths. In 3.3.5 each of these calls
// `FUN_0071F8F0(player, 0) → FUN_0060BF10(playerGUID, UNIT_FACTION_id)`
// which broadcasts the event for every unit token referencing the
// local player; vanilla just returns. We hook each path and fire
// `UNIT_FACTION("player")` post-original via the engine's printf-style
// dispatcher.
//
// Why one file and not four: every hook does the same shape work
// (snapshot rep-slot state, run original, compare, fire). The shared
// `LookupEngineEvent` + `FireUnitFactionPlayer` helpers are the value;
// splitting four 30-line hooks into four files would just spread that
// helper around.
//
// Why hook the inner setters (vs. `Script_*` wrappers): same coverage,
// and any future C++ caller of the setter inherits the polyfill.
//
// Coverage map:
//
// 1. `FUN_FACTION_SET_AT_WAR` — `FactionToggleAtWar` toggles
// AT_WAR. Hook fires UNIT_FACTION if the flag byte changed.
//
// 2. `FUN_FACTION_SET_INACTIVE` — `SetFactionInactive` /
// `SetFactionActive` toggle INACTIVE. Same change-detection
// shape as at-war.
//
// 3. `FUN_SMSG_SET_FACTION_STANDINGS` — server rep update.
// The handler mutates AT_WAR / VISIBLE bits as standing crosses
// thresholds (recovery from below-Hated clears the force-set
// AT_WAR). Snapshots the 64-entry flag-byte array before/after
// and fires once if any byte changed.
//
// 4. `FUN_SMSG_INITIALIZE_FACTIONS` — server initial sync at login.
// Fires unconditionally after the engine populates the rep table.
// Lets addons that observe UNIT_FACTION (rather than
// PLAYER_LOGIN / VARIABLES_LOADED) see the initial state.
//
// 5. `FUN_SMSG_SET_FACTION_ATWAR` — server force-change of AT_WAR
// (e.g., faction reset, server-side state change). Fires
// unconditionally after the engine writes the slot byte, matching
// 3.3.5's behavior — server-pushed packets are infrequent and
// always indicate a state change worth signaling.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Faction::UnitFactionPolyfill {
namespace {
// Helpers ────────────────────────────────────────────────────────────
// `UNIT_FACTION`'s event-table slot. The engine's `RebuildEventTable`
// preserves slot order across reloads and other DLLs claim only NULL
// gaps, so this is stable once resolved. Lazy-init on first fire.
int s_unitFactionSlot = -1;
void FireUnitFactionPlayer() {
if (s_unitFactionSlot < 0)
s_unitFactionSlot = Event::Custom::LookupByName("UNIT_FACTION");
if (s_unitFactionSlot < 0)
return;
using FireEventFn_t = void(__cdecl *)(int eventID, const char *format, ...);
auto fire = reinterpret_cast<FireEventFn_t>(Offsets::FUN_FIRE_EVENT);
fire(s_unitFactionSlot, "%s", "player");
}
uint8_t ReadRepSlotFlags(int repListID) {
return *reinterpret_cast<uint8_t *>(
Offsets::VAR_PLAYER_REP_SLOTS +
repListID * Offsets::REP_SLOT_STRIDE +
Offsets::OFF_REP_SLOT_FLAGS);
}
// Resolves `factionID → repListID` (0..63 rep-slot index in
// `VAR_PLAYER_REP_SLOTS`) by reading the `+0x04` field on the
// `Faction.dbc` record directly. Same value the engine's helper at
// `0x004D5600` returns — it just dereferences the same DBC field after
// its own bounds check. Returns -1 for factionIDs that don't resolve
// to a record (out of range / empty slot) or are header categories
// (record stores -1 in the field).
int FactionIDToRepListID(int factionID) {
if (factionID <= 0)
return -1;
const auto *record = DBC::Record(Offsets::VAR_FACTION_DBC_RECORDS,
Offsets::VAR_FACTION_DBC_COUNT,
static_cast<uint32_t>(factionID));
if (record == nullptr)
return -1;
return *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
}
// Hook 1 — at-war setter ──────────────────────────────────────────────
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
SetAtWar_t s_setAtWar_o = nullptr;
void __fastcall SetAtWar_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setAtWar_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // engine bailed (looting / PEACE_FORCED no-op / standing block)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookAtWar{
Offsets::FUN_FACTION_SET_AT_WAR,
reinterpret_cast<void *>(&SetAtWar_h),
reinterpret_cast<void **>(&s_setAtWar_o)};
// Hook 2 — inactive setter ────────────────────────────────────────────
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
SetInactive_t s_setInactive_o = nullptr;
void __fastcall SetInactive_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setInactive_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // no-op (already in requested state)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookInactive{
Offsets::FUN_FACTION_SET_INACTIVE,
reinterpret_cast<void *>(&SetInactive_h),
reinterpret_cast<void **>(&s_setInactive_o)};
// Hook 3 — SMSG_SET_FACTION_STANDINGS ─────────────────────────────────
// Snapshot all 64 rep-slot flag bytes into a packed buffer. The engine's
// per-rep-change handler mutates AT_WAR / VISIBLE bits as standing
// crosses thresholds, so we need a delta-detect across the whole table
// (single rep change may flip flags on an unrelated slot — and the SMSG
// can carry multiple rep updates in one packet).
struct RepFlagsSnapshot {
uint8_t flags[Offsets::MAX_REP_SLOTS];
};
void TakeSnapshot(RepFlagsSnapshot *out) {
for (int i = 0; i < Offsets::MAX_REP_SLOTS; ++i)
out->flags[i] = ReadRepSlotFlags(i);
}
bool SnapshotsDiffer(const RepFlagsSnapshot *a, const RepFlagsSnapshot *b) {
return std::memcmp(a->flags, b->flags, sizeof(a->flags)) != 0;
}
using SmsgHandler_t = int(__stdcall *)(uint32_t opcode, void *packet);
SmsgHandler_t s_smsgSetStandings_o = nullptr;
int __stdcall SmsgSetStandings_h(uint32_t opcode, void *packet) {
RepFlagsSnapshot before;
TakeSnapshot(&before);
const int result = s_smsgSetStandings_o(opcode, packet);
RepFlagsSnapshot after;
TakeSnapshot(&after);
if (SnapshotsDiffer(&before, &after))
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetStandings{
Offsets::FUN_SMSG_SET_FACTION_STANDINGS,
reinterpret_cast<void *>(&SmsgSetStandings_h),
reinterpret_cast<void **>(&s_smsgSetStandings_o)};
// Hook 4 — SMSG_INITIALIZE_FACTIONS ──────────────────────────────────
SmsgHandler_t s_smsgInitFactions_o = nullptr;
int __stdcall SmsgInitFactions_h(uint32_t opcode, void *packet) {
const int result = s_smsgInitFactions_o(opcode, packet);
// Initial sync — engine has just populated all 64 rep slots from
// server state. Always fire so addons listening for UNIT_FACTION
// (rather than PLAYER_LOGIN / VARIABLES_LOADED) see the initial
// load. Modern WoW's behavior.
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookInitFactions{
Offsets::FUN_SMSG_INITIALIZE_FACTIONS,
reinterpret_cast<void *>(&SmsgInitFactions_h),
reinterpret_cast<void **>(&s_smsgInitFactions_o)};
// Hook 5 — SMSG_SET_FACTION_ATWAR ─────────────────────────────────────
SmsgHandler_t s_smsgSetAtWar_o = nullptr;
int __stdcall SmsgSetAtWar_h(uint32_t opcode, void *packet) {
const int result = s_smsgSetAtWar_o(opcode, packet);
// Server-pushed AT_WAR change — packet is rare and always carries
// intent to update. Match 3.3.5's behavior: fire unconditionally
// after the byte write, without delta-detecting against the slot
// (the engine writes the byte regardless of whether it differs, so
// a delta check would conflate "value didn't change" with "server
// had nothing to say" — but the server only sends this when
// something did).
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetAtWar{
Offsets::FUN_SMSG_SET_FACTION_ATWAR,
reinterpret_cast<void *>(&SmsgSetAtWar_h),
reinterpret_cast<void **>(&s_smsgSetAtWar_o)};
} // namespace
} // namespace Faction::UnitFactionPolyfill
+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
+71 -1
View File
@@ -18,6 +18,12 @@
// per-frame `OnMouseDown` scripts — these fire even when the click
// misses any UI frame.
//
// `IsMouseButtonDown([button])` — query the current held state of a
// specific button by name or 1-based ID, or "any button held" when
// called with no args. State is maintained from the same hook stream
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -28,6 +34,9 @@
#include "Game.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
#include <windows.h>
namespace Input::GlobalMouse {
@@ -37,6 +46,19 @@ namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
constexpr int BIT_LEFT_BUTTON = 0;
constexpr int BIT_RIGHT_BUTTON = 1;
constexpr int BIT_MIDDLE_BUTTON = 2;
constexpr int BIT_BUTTON4 = 3;
constexpr int BIT_BUTTON5 = 4;
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
}
}
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
// any other string.
int NameToBitIdx(const char *name) {
if (name == nullptr)
return -1;
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
return -1;
}
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
bool down = false;
const char *name = DecodeButton(msg, wParam, &down);
if (name == nullptr)
return;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
if (down) g_mouseButtonMask |= bit;
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
int __fastcall Script_IsMouseButtonDown(void *L) {
bool down;
if (Game::Lua::GetTop(L) == 0) {
// No-arg form: any button held.
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
} else {
int bitIdx = -1;
if (Game::Lua::IsNumber(L, 1)) {
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (id >= 1 && id <= 5)
bitIdx = id - 1;
} else if (Game::Lua::IsString(L, 1)) {
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
}
// Unrecognized button (bad id, unknown name) → false, matching
// the modern API's "did this specific button happen to be held"
// semantic. Bad input doesn't promote to the any-button check.
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
}
Game::Lua::PushBool(L, down);
return 1;
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -88,7 +154,11 @@ void InstallHook() {
GetCurrentThreadId());
}
void RegisterLuaFunctions() { InstallHook(); }
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
+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
+265 -34
View File
@@ -17,6 +17,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include <cstdint>
@@ -52,16 +53,14 @@ static constexpr const char *kGetItemInfoReceived = "GET_ITEM_INFO_RECEIVED";
static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoadResult};
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
static void FireGetItemInfoReceived(int itemID, int success) {
const int slot = Event::Custom::Lookup(kGetItemInfoReceived);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", itemID, success);
static void FireGetItemInfoReceived(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
itemID, success);
}
static void FireItemDataLoadResult(int itemID, int success) {
const int slot = Event::Custom::Lookup(kItemDataLoadResult);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", itemID, success);
static void FireItemDataLoadResult(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
itemID, success);
}
// Engine callback for **implicit** cache fills (transparent warmup
@@ -76,16 +75,7 @@ static void FireItemDataLoadResult(int itemID, int success) {
// callback would crash the engine, so stdcall is verified by induction.
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
FireGetItemInfoReceived(itemID, static_cast<int>(success != 0));
}
// Engine callback for **explicit** cache fills (player-side
// `C_Item.RequestLoadItemData(ByID)` calls). Fires ITEM_DATA_LOAD_RESULT
// only — the implicit GET_ITEM_INFO_RECEIVED path is for ambient cache
// fills, not for callers who explicitly asked.
static void __stdcall ItemLoadCallback_Explicit(void *userData, int success) {
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
FireItemDataLoadResult(itemID, static_cast<int>(success != 0));
FireGetItemInfoReceived(itemID, success != 0);
}
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
@@ -103,6 +93,15 @@ using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
// VanillaHelpers documented as `requestIfMissing` — that slot is reused
// as a local immediately after entry, never read. The trigger for the
// request path is `callback != NULL`.
//
// The 6th arg IS used elsewhere though: when non-zero, the engine
// walks the entry's existing pending-callback list and skips the
// append if a node with matching `(callback, userData)` is already
// queued. We always pass `1` so that bursts of `WarmCache` calls for
// the same itemID (e.g. Bagnon enumerating the same slot repeatedly
// while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in flight) collapse to
// a single callback registration — otherwise the response fires our
// `GET_ITEM_INFO_RECEIVED` once per call.
static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -110,7 +109,7 @@ static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
void *cb = reinterpret_cast<void *>(callback);
void *userData =
callback ? reinterpret_cast<void *>(static_cast<uintptr_t>(itemID)) : nullptr;
return fn(cache, itemID, &zeroGuid, cb, userData, 0);
return fn(cache, itemID, &zeroGuid, cb, userData, 1);
}
// Resolves an item-location table at stack idx to the itemID by walking
@@ -148,18 +147,233 @@ static int __fastcall Script_IsItemDataCached(void *L) {
return 1;
}
// Explicit-request path: kicks off the cache fill via the explicit
// callback (which fires ITEM_DATA_LOAD_RESULT). If the item is already
// cached, the engine won't invoke our callback — synthesize the event
// so addons get the same notification regardless of cache state,
// matching modern `C_Item.RequestLoadItemData(ByID)` semantics.
static void RequestAndMaybeNotify(uint32_t itemID) {
const bool wasCached = (CacheFetch(itemID, nullptr) != nullptr);
CacheFetch(itemID, &ItemLoadCallback_Explicit);
if (wasCached)
FireItemDataLoadResult(static_cast<int>(itemID), 1);
// 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`.
//
// 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), `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 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 = 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) {
if (g_pending[i].itemID == itemID)
return i;
}
return -1;
}
static void RemovePendingAt(int idx) {
--g_pendingCount;
if (idx != g_pendingCount)
g_pending[idx] = g_pending[g_pendingCount];
}
// 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 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) {
// 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;
}
if (g_pendingCount >= kMaxPending)
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
// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and walks the per-entry callback
// lists, we sweep `g_pending` for anything now-loaded and fire the
// event. This is the engine-aligned alternative to per-tick polling:
// we observe fills exactly when they happen, with zero added latency
// and no risk of racing the engine's own callback walk.
//
// `__thiscall` is mimicked as `__fastcall(ecx, edx_unused, ...)` per
// the convention used elsewhere in this codebase (see
// `KeyringDescChange_h`, `FrameRegisterEvent_h`).
using ItemStatsCacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
static ItemStatsCacheResponse_t ItemStatsCacheResponse_o = nullptr;
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 (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;) {
if (TryCompletePending(i))
continue;
++i;
}
}
static const Game::HookAutoRegister _hookCacheResponse{
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
// Escalation + timeout. The hook covers the happy path (engine fills
// cache → we fire). This tick is only responsible for:
// 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_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);
FireByTag(itemID, false, implicit);
continue;
}
++g_pending[i].ticksWaiting;
++i;
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup
// path used by the `Script_GetItemInfo` hook and `SetItemByID`. Fires
// the cache request only if the item isn't already cached; uses the
@@ -167,7 +381,24 @@ static void RequestAndMaybeNotify(uint32_t itemID) {
// `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
@@ -190,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;
}
@@ -205,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
+5 -49
View File
@@ -166,49 +166,6 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Walks bags 0..4 looking for an item matching `arg`. On hit, sets
// `*outBag` / `*outSlot` / `*outItem` and returns true.
//
// itemID match is direct; name match peeks the item-cache record's
// `m_name[0]` and compares case-insensitively. Stomps the Lua stack
// per the helpers it calls.
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, int *outBag, int *outSlot,
const uint8_t **outItem) {
for (int bag = 0; bag <= 4; ++bag) {
const int slotCount = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slotCount; ++slot) {
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
*outBag = bag;
*outSlot = slot;
*outItem = item;
return true;
}
continue;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
*outBag = bag;
*outSlot = slot;
*outItem = item;
return true;
}
}
}
return false;
}
// `C_Item.EquipItemByName(itemInfo [, dstSlot])` — finds the first
// item in the player's bags matching `itemInfo` (itemID, link, or
// name) and equips it.
@@ -255,14 +212,13 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
reinterpret_cast<int(__fastcall *)(void *)>(
Offsets::FUN_SCRIPT_CLEAR_CURSOR)(L);
int bag = -1, slot = -1;
const uint8_t *cgItem = nullptr;
if (!FindItemInBags(L, arg, &bag, &slot, &cgItem)) {
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
if (hasDstSlot) {
Item::Swap::FromBag(cgItem, bag, slot, dstSlot);
Item::Swap::FromBag(found.item, found.bagID, found.slotIndex, dstSlot);
return 0;
}
@@ -273,8 +229,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bag));
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
+22 -33
View File
@@ -14,28 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Data.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Exists {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// `C_Item.DoesItemExist(itemLocation)` — true iff the location resolves
// to a populated inventory slot on the active player. Equipment-slot
// locations check the character pane; bag-slot locations walk the
@@ -51,27 +35,32 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
return 1;
}
// `C_Item.DoesItemExistByID(itemInfo)` — true iff the cache currently
// has data for this itemID (i.e. `GetItemInfo` would return non-nil
// right now). Same semantics as the modern function and the existing
// Lua shim: cache miss → false, but we kick off the network query
// so a subsequent call after `GET_ITEM_INFO_RECEIVED` will succeed.
// `C_Item.DoesItemExistByID(itemInfo)` — true if `itemID` looks like a
// valid item identifier. Modern WoW answers from the client-side item
// database (Item-sparse.db2) and is independent of whether the network
// cache has been warmed yet. 1.12 has no client-side DB, so we
// optimistically accept any positive itemID; callers needing the
// "is the cache populated?" check should use `IsItemDataCachedByID`.
//
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
// to load, so the existence check must succeed for items that aren't
// in the cache yet.
//
// We deliberately do NOT auto-warm the cache here. Existence is a pure
// query about the ID, not a load request — and during early-login
// (cold WDB, pfQuest's PLAYER_ENTERING_WORLD path) a spurious cache
// touch can race the engine's natural inventory prefetch and leave the
// item stuck. Callers wanting to actually load data should use
// `RequestLoadItemDataByID`; passive readers (`GetItemInfo`,
// `GetItemNameByID`) already warm on their own miss path.
//
// Accepts numeric itemID or `"item:NNN..."` string (including full
// chat links). Returns false for any input we can't resolve to an
// itemID; never raises.
// chat links); never raises.
static int __fastcall Script_C_Item_DoesItemExistByID(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
if (PeekItemRecord(static_cast<uint32_t>(itemID)) == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
Game::Lua::PushBoolean(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, 1);
Game::Lua::PushBoolean(L, itemID > 0);
return 1;
}
+417
View File
@@ -0,0 +1,417 @@
// 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.GetItemDataByID(itemInfo)` / `C_Item.GetItemData(itemLocation)`
// — return a single table containing every field we can extract from
// the cached `ItemStats_C` record. Convenience for addons that want
// the kitchen sink without making 15 separate `C_Item.*` calls.
//
// Returns `nil` if the item isn't cached (caller should
// `RequestLoadItemData` and listen for `ITEM_DATA_LOAD_RESULT`).
// Does not warm the cache itself — this is a passive reader, same as
// `GetItemInfoInstant`'s contract.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Spell.h"
#include <cstdint>
#include <cstdio>
namespace Item::GetData {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
int CurrentLocaleIndex() {
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
}
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
// the 9-locale name array; null/empty slots fall back to "".
const char *LookupClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
classID, Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
// (verbose name first, short name on miss).
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
if (count <= 0)
return "";
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
if (records == nullptr)
return "";
const int locale = CurrentLocaleIndex();
for (int i = 0; i < count; ++i) {
const uint8_t *record = records + i * Offsets::OFF_ITEMSUBCLASS_RECORD_STRIDE;
if (*reinterpret_cast<const uint32_t *>(record + 0x00) != classID)
continue;
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
continue;
const char *verbose = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
if (verbose != nullptr && verbose[0] != '\0')
return verbose;
const char *shortName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
}
return "";
}
const char *LookupInvType(uint32_t invType) {
if (invType > Offsets::INVTYPE_TABLE_MAX_INDEX)
return "";
auto **table = reinterpret_cast<const char **>(Offsets::VAR_INVTYPE_STRING_TABLE);
const char *s = table[invType];
return (s != nullptr && s[0] != '\0') ? s : "";
}
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
const char *iconName = DBC::StringField(
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
if (iconName == nullptr || iconName[0] == '\0')
return false;
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
return true;
}
// Wrath flipped the bagFamily encoding from raw 1-based IDs to a
// bitmask. We surface the modern shape so backported addons compile
// cleanly. See `Item::Info::BagFamilyIdToBitmask` for the full story.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// Shorthand: read a u32 field from the record at `offset`, push it on
// the stack as a Lua number, and set `t[key] = value` on the table at
// stack[-3]. `SetFieldNumber` already handles the push/set; this is
// just the field-fetch convenience.
void SetU32(void *L, const uint8_t *record, const char *key, int offset) {
const uint32_t v = *reinterpret_cast<const uint32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetI32(void *L, const uint8_t *record, const char *key, int offset) {
const int32_t v = *reinterpret_cast<const int32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetF32(void *L, const uint8_t *record, const char *key, int offset) {
const float v = *reinterpret_cast<const float *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
// Sets `t[key]` to a sub-array of N consecutive u32 values from the
// record. Pushes 1-indexed (Lua array convention).
void SetU32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const uint32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetI32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const int32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetF32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const float *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Sparse stats: walks the 10 (statType, statValue) slots and pushes
// only the populated ones into a map keyed by statType. Modern addons
// expect `stats[STAT_AGI] = 5`-style access; matching that here avoids
// callers having to walk the fixed-10 array themselves.
void SetStatsMap(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "stats");
Game::Lua::NewTable(L);
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
if (types[i] == 0 && values[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(types[i]));
Game::Lua::PushNumber(L, static_cast<double>(values[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Spell slots: walks all 5 slots, pushes only entries with a non-zero
// spellID as `{ id, trigger, charges, cooldown, category,
// categoryCooldown }` records. Sparse but 1-indexed in declaration
// order so the slot index is preserved.
void SetSpellsArray(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "spells");
Game::Lua::NewTable(L);
auto *ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto *charges = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
auto *cooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
auto *categories = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
int written = 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
if (ids[i] == 0)
continue;
++written;
Game::Lua::PushNumber(L, static_cast<double>(written));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "trigger", static_cast<double>(triggers[i]));
Game::Lua::SetFieldNumber(L, "charges", static_cast<double>(charges[i]));
Game::Lua::SetFieldNumber(L, "cooldown", static_cast<double>(cooldowns[i]));
Game::Lua::SetFieldNumber(L, "category", static_cast<double>(categories[i]));
Game::Lua::SetFieldNumber(L, "categoryCooldown",
static_cast<double>(catCooldowns[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Pushes a single Lua table for `record` onto the stack, populated
// with every field we can read out of `ItemStats_C`. Caller is
// responsible for verifying `record` is non-null and pushing the
// itemID as a setup step.
void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
// Identity strings
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
Game::Lua::SetFieldString(L, "name", name);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
if (description != nullptr && description[0] != '\0')
Game::Lua::SetFieldString(L, "description", description);
// Display
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
char iconPath[260];
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
Game::Lua::SetFieldString(L, "icon", iconPath);
// Quality / classification
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
// Bind
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
// Flags: raw + decoded bits.
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isConjured",
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
Game::Lua::SetFieldBool(L, "isOpenable",
(flags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0);
Game::Lua::SetFieldBool(L, "isLootable",
(flags & Offsets::ITEM_FLAG_LOOTABLE) != 0);
Game::Lua::SetFieldBool(L, "isWrapper",
(flags & Offsets::ITEM_FLAG_WRAPPER) != 0);
// Stacking / inventory
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::SetFieldNumber(L, "bagFamily",
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
// Economy
SetU32(L, record, "buyPrice", Offsets::OFF_ITEMSTATS_BUY_PRICE);
SetU32(L, record, "sellPrice", Offsets::OFF_ITEMSTATS_SELL_PRICE);
// Level / requirements
SetU32(L, record, "itemLevel", Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
SetU32(L, record, "requiredLevel", Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
SetU32(L, record, "requiredSkill", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
SetU32(L, record, "requiredSkillRank", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
SetU32(L, record, "requiredSpell", Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
SetU32(L, record, "requiredHonorRank", Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
SetU32(L, record, "requiredCityRank", Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
SetU32(L, record, "requiredFaction", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
SetU32(L, record, "requiredFactionRank", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
SetU32(L, record, "allowableClass", Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
SetU32(L, record, "allowableRace", Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
// Equipment defenses
SetU32(L, record, "armor", Offsets::OFF_ITEMSTATS_ARMOR);
SetU32(L, record, "block", Offsets::OFF_ITEMSTATS_BLOCK);
SetU32(L, record, "maxDurability", Offsets::OFF_ITEMSTATS_MAX_DURABILITY);
// Sparse stats map (statType -> statValue, only populated entries)
SetStatsMap(L, record);
// Resistances
SetU32(L, record, "resistanceHoly", Offsets::OFF_ITEMSTATS_RES_HOLY);
SetU32(L, record, "resistanceFire", Offsets::OFF_ITEMSTATS_RES_FIRE);
SetU32(L, record, "resistanceNature", Offsets::OFF_ITEMSTATS_RES_NATURE);
SetU32(L, record, "resistanceFrost", Offsets::OFF_ITEMSTATS_RES_FROST);
SetU32(L, record, "resistanceShadow", Offsets::OFF_ITEMSTATS_RES_SHADOW);
SetU32(L, record, "resistanceArcane", Offsets::OFF_ITEMSTATS_RES_ARCANE);
// Weapon
SetF32Array(L, record, "damageMin", Offsets::OFF_ITEMSTATS_DAMAGE_MIN,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetF32Array(L, record, "damageMax", Offsets::OFF_ITEMSTATS_DAMAGE_MAX,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32Array(L, record, "damageType", Offsets::OFF_ITEMSTATS_DAMAGE_TYPE,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32(L, record, "delay", Offsets::OFF_ITEMSTATS_DELAY);
SetU32(L, record, "ammoType", Offsets::OFF_ITEMSTATS_AMMO_TYPE);
SetF32(L, record, "rangedModRange", Offsets::OFF_ITEMSTATS_RANGED_MOD_RANGE);
// Spell slots (sparse array of records) + ON_USE convenience
SetSpellsArray(L, record);
const int useSpellID = Item::Spell::OnUseSpellIDForItemID(itemID);
if (useSpellID > 0)
Game::Lua::SetFieldNumber(L, "useSpellID", static_cast<double>(useSpellID));
// Lock / item set
SetU32(L, record, "lockID", Offsets::OFF_ITEMSTATS_LOCK_ID);
SetU32(L, record, "itemSet", Offsets::OFF_ITEMSTATS_ITEM_SET);
// Books / quest / binding
SetU32(L, record, "pageText", Offsets::OFF_ITEMSTATS_PAGE_TEXT);
SetU32(L, record, "pageMaterial", Offsets::OFF_ITEMSTATS_PAGE_MATERIAL);
SetU32(L, record, "languageID", Offsets::OFF_ITEMSTATS_LANGUAGE_ID);
SetU32(L, record, "startQuest", Offsets::OFF_ITEMSTATS_START_QUEST);
SetI32(L, record, "material", Offsets::OFF_ITEMSTATS_MATERIAL);
SetU32(L, record, "sheath", Offsets::OFF_ITEMSTATS_SHEATH);
SetI32(L, record, "randomProperty", Offsets::OFF_ITEMSTATS_RANDOM_PROPERTY);
SetU32(L, record, "area", Offsets::OFF_ITEMSTATS_AREA);
SetU32(L, record, "map", Offsets::OFF_ITEMSTATS_MAP);
}
// Common path for both entry points: take an itemID, peek the cache,
// build & push the data table or push nil for uncached.
int PushDataForItemID(void *L, int itemID) {
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
PushItemDataTable(L, static_cast<uint32_t>(itemID), record);
return 1;
}
// `C_Item.GetItemDataByID(itemInfo)` — accepts numeric itemID,
// `"item:NNN..."` string, or full chat link. Returns the data table
// or nil for invalid input / uncached items.
int __fastcall Script_GetItemDataByID(void *L) {
return PushDataForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// `C_Item.GetItemData(itemLocation)` — resolves the equipment-slot /
// bag-slot location to its CGItem, extracts the itemID, then peeks
// the cache. Returns nil for empty slots, invalid locations, or
// uncached items.
int __fastcall Script_GetItemData(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemData(itemLocation)");
return 0;
}
return PushDataForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemDataByID", &Script_GetItemDataByID);
Game::Lua::RegisterTableFunction("C_Item", "GetItemData", &Script_GetItemData);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::GetData
+20 -3
View File
@@ -93,13 +93,31 @@ static bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
return true;
}
// `GetItemInfoInstant` is the modern "synchronous, never blocks" item
// info call. In modern WoW the trailing 6 fields come from client-side
// `Item-sparse.db2` and so the call always returns the full 7-tuple.
// 1.12 has no client-side item DB — those fields live only in the
// network-fed `DBCache_ItemStats`. We mirror the contract by:
// - always returning the itemID (echoed from input) so callers using
// `GetItemInfoInstant(link_or_id)` as an ID extractor never see nil
// - returning the remaining 6 fields from the cache when present,
// nil when not (preserving the 7-tuple shape)
// - not warming the cache; "Instant" implies no side effects. Callers
// that need the full info should use `GetItemInfo`, which already
// triggers the warmup via the `Script_GetItemInfo` hook.
static int __fastcall Script_GetItemInfoInstant(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(itemID));
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return 0;
if (record == nullptr) {
for (int i = 0; i < 6; ++i)
Game::Lua::PushNil(L);
return 7;
}
const uint32_t classID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
@@ -114,7 +132,6 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
Game::Lua::PushNumber(L, static_cast<double>(itemID));
Game::Lua::PushString(L, LookupItemClassName(classID));
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID));
Game::Lua::PushString(L, LookupInvType(invType));
+51 -4
View File
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
}
// ── Proficiency gate ──────────────────────────────────────────────
// `FitsSlot` only checks invType→slot shape compatibility; without
// also gating on the player's proficiencies this would return (e.g.)
// mail helms for a warlock since both are `invType=HEAD`. Modern
// WoW's equipment-set picker filters by what the player can actually
// equip; we mirror that.
//
// Source of truth: the engine's per-itemClass proficiency bitmap at
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
// for each class verbatim from the wire. Bit N of `table[class]` =
// "player can equip subclass N of this class". Same data the engine
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
// proficiencies in one lookup.
//
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
if (itemClass > 16)
return true; // table only covers 0..16; be permissive past that
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
if (mask == 0)
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
// — fail open so empty-mask doesn't blank the list
return (mask & (1u << subclass)) != 0;
}
bool PlayerCanEquip(const uint8_t *record) {
if (record == nullptr)
return true; // missing record — be permissive, don't hide the item
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
return PlayerHasProficiency(classCode, subclass);
}
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
uint32_t ReadInventoryType(const uint8_t *cgItem) {
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID <= 0)
return 0;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
return nullptr;
return PeekItemRecord(static_cast<uint32_t>(itemID));
}
uint32_t ReadInventoryType(const uint8_t *record) {
if (record == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
std::vector<Hit> &out) {
if (cgItem == nullptr)
return;
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
const uint8_t *record = ItemRecordForCGItem(cgItem);
if (!FitsSlot(slot, ReadInventoryType(record)))
return;
if (!PlayerCanEquip(record))
return;
out.push_back({location, cgItem});
}
+37
View File
@@ -16,13 +16,31 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include <cstdint>
#include <cstdio>
namespace Item::Link {
namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int dedupFlag);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
const char *FromCGItem(const uint8_t *cgItem) {
@@ -32,6 +50,25 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr)
return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return false;
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+12
View File
@@ -13,6 +13,7 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace Item::Link {
@@ -28,4 +29,15 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
// only an itemID and no live CGItem (mail inbox attachments, cursor
// drag-source items, vendor roster previews). Returns true on
// success, false if the item isn't cached or the buffer is too small.
//
// `outSize` should be at least 256 to comfortably fit the longest
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
} // namespace Item::Link
+41
View File
@@ -16,8 +16,11 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "Arg.h"
#include "ID.h"
#include <cstring>
namespace Item::Location {
namespace {
@@ -183,6 +186,44 @@ bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
return false;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
return false;
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+20
View File
@@ -96,6 +96,26 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
// Used by the by-name action APIs (`C_Item.EquipItemByName`,
// `C_Item.UseItemByName`, `C_Item.UseAtCursor`) — none of them want
// equipment in the candidate pool (the engine doesn't equip-from-
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+50
View File
@@ -199,6 +199,38 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
dstPaperdollSlot);
}
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag) {
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
if (!ReadGuid(player, &playerLo, &playerHi))
return false;
uint32_t dstContainerLo = 0, dstContainerHi = 0, dstLinear = 0;
if (!EncodeBagSlot(dstBagID, dstSlotInBag,
&dstContainerLo, &dstContainerHi, &dstLinear))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadGuid(cgItem, &itemLo, &itemHi))
return false;
auto fn = reinterpret_cast<SwapFn_t>(Offsets::FUN_INVENTORY_SWAP);
fn(player,
itemLo, itemHi,
playerLo, playerHi,
static_cast<uint32_t>(srcPaperdollSlot - 1),
dstContainerLo, dstContainerHi, dstLinear,
0);
return true;
}
bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int count) {
// Vanilla protocol writes count as a single byte. Clamp at 255;
// anything larger would either truncate silently (255 sent) or
@@ -222,6 +254,24 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (srcItem == nullptr)
return false;
// Vanilla server rejects `CMSG_SPLIT_ITEM` when count == srcStack
// (`EQUIP_ERR_COULDNT_SPLIT_ITEMS`) — conceptually "split N off,
// leave residual", and splitting the entire stack would leave
// source empty. The drag-and-drop UI's "drop whole stack on
// matching stack" goes through CMSG_SWAP_ITEM instead, which the
// server treats as a merge for same-item destinations (up to
// maxStack). Route there for count == srcStack so callers don't
// have to special-case "move everything".
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
const int srcStack = srcDescriptor == nullptr ? 0 :
static_cast<int>(*reinterpret_cast<const uint32_t *>(
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
if (count > srcStack)
return false; // server would reject anyway; fail fast and locally
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
if (player == nullptr)
return false;
+17 -3
View File
@@ -32,6 +32,12 @@ namespace Item::Swap {
// paperdoll slot `srcSlot` (1-based). Used for paperdoll-to-
// paperdoll swaps like rings 11/12, trinkets 13/14.
//
// - `ToBag(item, srcPaperdollSlot, dstBag, dstSlot)` — item currently
// in paperdoll slot, moving to a bag slot. The "unequip" direction
// `UseEquipmentSet` needs for slots the set wants empty. Uses the
// same atomic swap packet — server-side accept rules are identical
// (bagID 0..4, dst slot must be valid for that bag).
//
// All slot args are 1-based Lua-facing; helpers do the
// linear-slot conversion. Returns `false` on bad args (slot out of
// range, no player object, missing instance block on the item or
@@ -44,6 +50,8 @@ bool FromBag(const void *cgItem, int bagID, int slotInBag, int dstPaperdollSlot)
bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlot);
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag);
// General bag-to-bag swap — neither side is constrained to the
// paperdoll. Same engine helper (`FUN_INVENTORY_SWAP`), which routes
// to opcode 0x10D (same-container slot swap, e.g. backpack ↔ backpack)
@@ -74,9 +82,15 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot);
// - `dst` same item & fits maxStack → merges `count` into dst
// - `dst` different item / overflow → server rejects (no partial)
//
// Source must be occupied with at least `count` items; server rejects
// if not. Returns false locally only on bad args or unresolvable bags.
// All other validation is server-side — same as `Containers` above.
// Source must be occupied with at least `count` items. Locally
// validates `count <= srcStack` (server would reject otherwise with
// `EQUIP_ERR_COULDNT_SPLIT_ITEMS` — fail fast). When `count ==
// srcStack` ("move the whole stack"), routes through `Containers`
// instead — vanilla's `CMSG_SPLIT_ITEM` rejects splits that would
// leave the source empty, but `CMSG_SWAP_ITEM` handles the case
// (merges into a matching destination stack, swaps otherwise).
// Returns false locally on bad args, unresolvable bags, or
// `count > srcStack`; all other validation is server-side.
//
// Requires Lua state for the same reason as `Containers`: the source
// CGItem lookup goes through `Item::Location::ResolveBag`. Stomps the
+75
View File
@@ -21,6 +21,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Item::Tooltip {
@@ -290,8 +291,82 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
return 1;
}
// `GameTooltip:SetItemByGUID(guidString)` — modern method that renders
// the tooltip for the specific item instance identified by GUID
// (`"0xHHHHHHHHLLLLLLLL"` per `C_Item.GetItemGUID`). Distinct from
// `SetItemByID`, which shows the base `ItemSparse` data with no
// per-instance state: this path uses the live CGItem so the tooltip
// includes enchant lines, random-suffix-decorated name + bonuses,
// locked/broken state, etc.
//
// Implementation: parse the GUID string, resolve via the engine's
// `FUN_OBJECT_RESOLVE_BY_GUID` (same path `C_Item.GetItemLocation`
// and `GameTooltip:GetItem` use), build the fully-decorated
// hyperlink via `Item::Link::FromCGItem` (engine helper at
// `FUN_GAMETOOLTIP_BUILD_ITEM_LINK`), then dispatch to the engine's
// existing `Script_GameTooltip_SetHyperlink`. SetHyperlink parses
// the link's `item:N:enchant:gem:gem:gem:gem:suffix:unique` payload
// back into instance-specific tooltip data — closing the loop
// without us needing a separate engine entry point for "tooltip
// from CGItem*".
//
// Silent no-op when the GUID is malformed, not loaded in the
// client, or doesn't resolve to an item (creature/object GUIDs go
// to a different resolver).
static int __fastcall Script_GameTooltipSetItemByGUID(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
if (Game::Lua::Type(L, 2) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 2);
uint64_t guid = 0;
if (!Item::Location::ParseGUIDString(guidStr, &guid))
return 0;
const uint8_t *cgItem = Item::Location::ResolveByGUID(guid);
if (cgItem == nullptr)
return 0;
const char *link = Item::Link::FromCGItem(cgItem);
if (link == nullptr || *link == '\0')
return 0;
// `Item::Link::FromCGItem` returns the full
// `|cffXXXXXXXX|Hitem:N:E:S:S:S:S:R:U|h[Name]|h|r` decorated link
// (the public-link form addons paste into chat). `SetHyperlink`
// wants just the bare `item:N:E:S:S:S:S:R:U` payload — passing the
// decorated form trips its "Unknown link type" Lua error because
// the parser sees `|c` first and gives up. Extract the payload by
// finding `|Hitem:` and copying through to the closing `|h`.
const char *open = std::strstr(link, "|Hitem:");
if (open == nullptr)
return 0;
open += 2; // skip past `|H` so we start at `item:`
const char *close = std::strstr(open, "|h");
if (close == nullptr)
return 0;
const size_t payloadLen = static_cast<size_t>(close - open);
char payload[128];
if (payloadLen + 1 > sizeof(payload))
return 0;
std::memcpy(payload, open, payloadLen);
payload[payloadLen] = '\0';
// Replace stack[2] (the GUID) with the bare item link and
// dispatch to SetHyperlink with (self, "item:...").
Game::Lua::SetTop(L, 1);
Game::Lua::PushString(L, payload);
using Script_t = int(__fastcall *)(void *L);
auto fn = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK);
return fn(L);
}
static const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetItemByID", &Script_GameTooltipSetItemByID},
{"SetItemByGUID", &Script_GameTooltipSetItemByGUID},
{"SetInventoryItemByID", &Script_GameTooltipSetInventoryItemByID},
{"GetItem", &Script_GameTooltipGetItem},
{"HasItem", &Script_GameTooltipHasItem},
+62 -45
View File
@@ -11,18 +11,31 @@
// 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.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
// only uniqueness signal vanilla's client carries. `1` = unique
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
// unlimited; higher values cap the inventory count.
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
// item's cached record; that's the only uniqueness signal vanilla
// carries. `1` = unique-equipped (one at a time); higher values
// cap the inventory count; `0` = unlimited.
//
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
// Brewfest Mug"). Vanilla has no category system, so we surface
// `m_maxCount` as the first return and leave the category nil and
// equipped count `0` — matching modern's "field present but inapplicable"
// convention.
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
// `ItemDocumentation.lua`:
//
// GetItemUniqueness(itemLocation) →
// limitCategory (number, non-nilable),
// limitMax (number, non-nilable)
//
// GetItemUniquenessByID(itemInfo) →
// isUnique (bool, non-nilable),
// limitCategoryName (string, nilable),
// limitCategoryCount (number, nilable),
// limitCategoryID (number, nilable)
//
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
// so the category fields are always nil/0. `isUnique` collapses to
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
// the raw `m_maxCount`.
#include "Game.h"
#include "Offsets.h"
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
// ItemStats_C record, or nil + early-return on cache miss.
int PushFromItemID(void *L, uint32_t itemID) {
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
// so the Lua side sees the modern "MayReturnNothing" behaviour.
int ReadMaxCount(uint32_t itemID) {
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr) {
Item::Data::WarmCache(itemID);
Game::Lua::PushNil(L);
return 1;
return -1;
}
const int maxCount = *reinterpret_cast<const int *>(
return *reinterpret_cast<const int *>(
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
Game::Lua::PushNil(L); // category (vanilla has none)
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
return 3;
}
} // namespace
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
static int __fastcall Script_GetItemUniqueness(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Item::Location::IsLocationArg(L, 1))
return 0;
auto *item = Item::Location::Resolve(L, 1);
if (item == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (item == nullptr)
return 0;
const int itemID = Item::ID::FromCGItem(item);
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0; // cache miss — modern API documents "MayReturnNothing"
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
return 2;
}
// `GetItemUniquenessByID(itemID)` shape:
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
static int __fastcall Script_GetItemUniquenessByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0;
Game::Lua::PushBool(L, maxCount > 0);
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
if (maxCount > 0)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
else
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
Game::Lua::PushNil(L); // limitCategoryID
return 4;
}
static void RegisterLuaFunctions() {
+82 -54
View File
@@ -11,9 +11,12 @@
// 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.UseItemByName(itemInfo)` — finds the first item in the
// player's bags matching `itemInfo` (itemID, link, or name) and uses
// it via the engine's `CGItem::UseItem` primitive.
// `C_Item.UseItemByName(itemInfo [, unit])` — finds the first item in
// the player's bags matching `itemInfo` (itemID, link, or name) and
// uses it via the engine's `CGItem::UseItem` primitive. Optional
// `unit` is a unit token (`"player"`, `"target"`, `"focus"`, …)
// passed to spell-cast items (scrolls, traps, on-use targeted
// effects) as the cast target.
//
// Same structure 3.3.5's `Script_UseItemByName` uses: locate the item
// directly, then hand it to the engine's by-item-pointer use call.
@@ -25,91 +28,116 @@
// food / potion / hearthstone / scroll / quiver all route through
// the same entry.
//
// Target arg: we pass nullptr / a zero GUID. The engine substitutes
// the player itself for self-use items (hearthstone, food, potion);
// for target-required items (scrolls, traps) the engine reads
// cursor-target state at call time, which a Lua-side caller can
// pre-set with `TargetUnit("...")`.
// Target arg: `FUN_ITEM_USE`'s `param_1` is read as a `uint64_t *`
// pointing at the target's GUID. The engine writes it through to the
// "use item" event (`DAT_00c4c1d0`/`DAT_00c4c1d4` + event 0x122) for
// spell-cast items, and into the cast-targeting helper at
// `FUN_006e5a90`. For self-use items (hearthstone, food, potion) the
// engine overwrites with the item's own GUID before dispatch, so a
// stray target is harmless. We default to a zero GUID when the caller
// omits `unit` — matches existing call sites.
//
// Returns nothing. Silently no-ops on bad input, item not found in
// bags, item locked, on cooldown, etc.
// bags, item locked, on cooldown, etc. An unrecognized `unit` string
// is treated as "no target" rather than raising, matching arg-1's
// "silently no-ops on bad input" contract.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include <cstdint>
#include <string.h>
#include <cstring>
namespace Item::Use {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Returns the matching CGItem* in `*outItem` on hit; mirrors the
// `Item::Equipment::FindItemInBags` walk but skips bag/slot output
// since `FUN_ITEM_USE` operates directly on the CGItem pointer.
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, const uint8_t **outItem) {
for (int bag = 0; bag <= 4; ++bag) {
const int slotCount = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slotCount; ++slot) {
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr) continue;
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
*outItem = item;
return true;
}
continue;
}
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
*outItem = item;
return true;
}
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
int __fastcall Script_C_Item_UseItemByName(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
return 0;
}
const uint8_t *cgItem = nullptr;
if (!FindItemInBags(L, arg, &cgItem)) {
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t zeroTarget = 0;
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(cgItem, &zeroTarget, 0);
useItem(found.item, &targetGuid, 0);
return 0;
}
+76
View File
@@ -0,0 +1,76 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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.UseAtCursor(itemInfo)` — use the named item at the player's
// current cursor world position. For items whose on-use spell is
// ground-target (Iron Grenade, demolition charges, etc.), bypasses
// the manual click on the AoE reticle that the engine would
// otherwise require.
//
// Mirrors `C_Item.UseItemByName`'s bag-walk + `FUN_ITEM_USE` dispatch
// — the engine's item-use path eventually hands the on-use spell to
// the same cast dispatcher that ground-target spells go through, so
// placement-mode activation works identically. After firing the
// item, `Spell::AtCursor::Resolve()` commits the placement at cursor.
//
// Returns `true` when the cursor-placement leg landed; `false` for
// items that aren't ground-target (the item still fires, just with
// no implicit target), unparseable input, item-not-in-bags, etc.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "spell/AtCursor.h"
#include <cstdint>
namespace Item::UseAtCursor {
namespace {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
int __fastcall Script_C_Item_UseAtCursor(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr) {
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::Resolve();
Game::Lua::PushBool(L, placed);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "UseAtCursor",
&Script_C_Item_UseAtCursor);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::UseAtCursor
+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
+227
View File
@@ -0,0 +1,227 @@
// 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 "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include <cstdint>
namespace Loot::Nearby {
namespace {
// Engine interact-range constants, read out of `.rdata` once and
// pinned here as `constexpr`. Both are referenced by `FUN_005EC110`
// (the master interact-state checker) and propagated through
// `FUN_005DF130` / `FUN_005DF2A0` (CMSG_LOOT senders). Yards units;
// values verified at runtime addresses `DAT_0080B058` and
// `DAT_0080A1E8`.
//
// INTERACT_REACH (1.333…) is added to `target_radius + player_radius`
// for the unclamped range; modeled as the "reach beyond your touch"
// constant.
// MIN_INTERACT_RANGE (5.0) clamps the result so even tiny
// targets with near-zero bounding radius still get the standard
// 5-yard right-click reach.
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// `ClntObjMgrEnumVisibleObjects(callback, context)` — walks the engine's
// visible-object table. The callback shape's `void *unusedEdx` slot
// absorbs the second `__fastcall` register so MSVC lays out the trailing
// `uint64_t guid` on the stack exactly where the engine puts it. The
// callback returns non-zero to keep iterating; zero stops the walk early.
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
void *unusedEdx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
// caller-provided 12-byte buffer with the unit's world position and
// return it. Same call shape `FUN_005EC110` uses to read the
// player / target positions when deciding interact range.
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
// `ClntObjMgrObjectPtr(typeMask, debugMessage, guid_lo, guid_hi, debugCode)`
// — resolves a 64-bit GUID to the loaded `CGObject_C *` it currently
// refers to, filtered by `typeMask`. Returns NULL when no live object
// matches the GUID *and* the typeMask. Splitting the GUID into lo/hi
// DWORDs on the stack is equivalent to the engine's
// `__fastcall(uint32_t, const char*, uint64_t, int)` signature once the
// `uint64_t` argument is placed in the same stack slots.
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
// Reads `(unit.m_objectFields[+0x224] & UNIT_DYNFLAG_LOOTABLE) != 0`
// — the server-driven bit it sets when the local player has loot
// rights on this unit. Works uniformly for live and dead CGUnit
// subclasses; the bit is simply 0 for non-lootable targets.
//
// Don't call `FUN_CGCORPSE_IS_LOOTABLE` here even though the engine
// itself uses it in `FUN_005D6BF0`: that helper reads `+0x78` of
// m_objectFields, which is the CORPSE dynamic-flags slot for
// CGCorpse_C but the BYTES_0 (race / class / gender / power type)
// slot for CGUnit_C. Routing live units through it returns the low
// bit of *race* — yields false positives for any Human / Dwarf /
// Undead / Gnome player and false negatives for dead mobs.
bool IsLootable(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
// Reads the bounding-radius float from a unit's m_objectFields. The
// engine uses this as the contribution from both sides (player + target)
// in its interact-range computation.
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Invokes the unit's virtual `GetPosition` (vtable slot at offset
// `0x14`). The engine's signature is `__thiscall(this, outBuf) →
// outBuf`; we pass our own 12-byte stack buffer and read x/y/z out
// of the returned pointer.
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
// Mirrors the range portion of `FUN_005EC110`. We skip the engine's
// player-state checks (mounted / casting / prior-interaction GUID
// gate) because they fire intermittently during normal play and would
// make the enumerator return an empty list whenever the player has
// already clicked something — surprising for a "what's lootable
// nearby" reader. The pure distance test is what controls cursor
// highlight, which is the contract the user asked for.
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// Only `TYPEMASK_UNIT` carries lootable-mob state in 1.12 — dead
// creatures stay as CGCreature_C objects with the lootable bit flipped
// on, rather than transitioning to a separate corpse object. Dead
// players become CGCorpse_C (`TYPEMASK_CORPSE`) but those aren't
// lootable by other players in vanilla — no reason to walk them here.
struct ScanCtx {
void *L;
const void *player; // resolved once outside the iterator
int idx; // 1-based slot of the next entry to append
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1; // GUID isn't a unit; continue
if (!IsLootable(obj))
return 1; // unit isn't lootable to us; continue
if (!InInteractRange(ctx->player, obj))
return 1; // out of click-loot range; continue
char guidBuf[Guid::STRING_SIZE];
Game::Lua::PushNumber(ctx->L, static_cast<double>(ctx->idx));
Game::Lua::NewTable(ctx->L);
Game::Lua::SetFieldString(ctx->L, "guid",
Guid::FormatAsString(guid, guidBuf, sizeof guidBuf));
Game::Lua::SetTable(ctx->L, -3);
++ctx->idx;
return 1;
}
// `C_Loot.GetNearbyLootableUnits()` — returns a 1-indexed array of
// `{ guid = "0x..." }` subtables, one per visible unit that is (a)
// flagged DYNFLAG_LOOTABLE by the server and (b) inside the engine's
// own interact range (the distance threshold that lights up the
// right-click-loot cursor). Empty table pre-login, when no lootable
// units are in range, or when we don't have loot rights on visible
// corpses (the server only sets the bit for clients with rights).
int __fastcall Script_GetNearbyLootableUnits(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
// The engine derefs the object manager unconditionally on iterator
// entry. NULL during glue / character-select; bail with an empty
// table rather than crashing.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return 1;
// Resolve the local player once — the canonical CGPlayer_C the
// engine's own interact-range checks use, distinct from the
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
const void *player = ResolveUnitToken("player");
if (player == nullptr)
return 1;
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
ScanCtx ctx{L, player, 1};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&ScanCallback),
&ctx);
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "GetNearbyLootableUnits",
&Script_GetNearbyLootableUnits);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Nearby
+479
View File
@@ -0,0 +1,479 @@
// 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.
//
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
// the engine's loot-window response* before any visual updates happen.
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
// the engine's state machine cycles silently for each corpse and
// `LootFrame` never reacts. Other Lua addons listening to those events
// also see nothing during the scan; that's deliberate.
//
// Compared to the older polling-driven version this also collapses the
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
// state transition.
#include "../Game.h"
#include "../Offsets.h"
#include "../event/Custom.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
#include <cstdint>
#include <string>
#include <vector>
namespace Loot::Scan {
namespace {
constexpr const char *kEventCompleted = "LOOT_SCAN_COMPLETED";
const Event::Custom::AutoReserve _reserve{kEventCompleted};
// Per-step timeout for the controller hook to fire on a queued
// CMSG_LOOT. WorldTick fires at the client frame rate (~30-60 Hz),
// so 180 ticks ≈ 3-6s wall clock. Long enough for high-latency
// servers, short enough that a single dropped response doesn't hang
// the whole scan.
constexpr int kStepTimeoutTicks = 180;
// Event IDs the engine fires through `FUN_FIRE_EVENT_NO_ARGS` for
// loot-window transitions. The hook swallows these only when the
// scanner has the suppression flag raised — normal play sees them
// fire unmodified.
constexpr int EVENT_LOOT_OPENED = 0x10B;
constexpr int EVENT_LOOT_CLOSED = 0x10D;
enum class State {
Idle,
WaitingForResponse, // CMSG_LOOT sent; awaiting our controller hook
};
struct SlotItem {
uint32_t itemID;
uint32_t count;
std::string link; // empty if the cache record wasn't loaded
};
struct LootEntry {
uint64_t guid;
uint32_t coin; // copper amount; 0 if no coin
std::vector<SlotItem> items;
};
struct ScanCtx {
State state = State::Idle;
void *player = nullptr;
uint64_t currentGuid = 0;
int ticksSinceTransition = 0;
bool suppressEvents = false; // read by the FireEvent hook
std::vector<uint64_t> queue;
std::vector<LootEntry> results;
};
ScanCtx s_scan;
// === Typedefs for engine helpers ===
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
void *unusedEdx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
char earlyReturnOnGameObject,
char showError);
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
char *outBuf,
int bufSize);
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
// the engine doesn't pop, drifting ESP by 4 bytes per call until a
// later return reads `0x00000001` as a corrupt frame pointer and the
// process crashes. We do see / forward the `coin` value because the
// engine reads it as `DAT_00b71ba0` later.
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
int /*edx unused*/);
struct C3Vector {
float x, y, z;
};
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
C3Vector *outBuf);
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
constexpr float INTERACT_REACH = 1.3333333f;
constexpr float MIN_INTERACT_RANGE = 5.0f;
// === Range / lootability helpers (identical to Loot::Nearby) ===
bool IsLootableUnit(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
auto vtable = *reinterpret_cast<void *const *const *>(unit);
auto fn = reinterpret_cast<GetPosition_t>(
*reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(vtable) +
VTBL_GET_POSITION_OFFSET));
return fn(unit, outBuf);
}
bool InInteractRange(const void *player, const void *target) {
C3Vector pBuf{};
C3Vector tBuf{};
const C3Vector *pPos = GetPosition(player, &pBuf);
const C3Vector *tPos = GetPosition(target, &tBuf);
if (pPos == nullptr || tPos == nullptr)
return false;
const float dx = pPos->x - tPos->x;
const float dy = pPos->y - tPos->y;
const float dz = pPos->z - tPos->z;
const float distSq = dx * dx + dy * dy + dz * dz;
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
if (range < MIN_INTERACT_RANGE)
range = MIN_INTERACT_RANGE;
return distSq <= range * range;
}
// === Scrape ===
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
// The controller hook calls this *after* letting the original
// `FUN_LOOT_CONTROLLER` run, so the slot table is fully filled in
// and the link builder works against it.
void ScrapeCurrentLoot(LootEntry *out) {
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
out->coin = (coinRaw == 0xFFFFFFFFu) ? 0u : coinRaw;
const bool hasCoin = coinRaw != 0;
auto LinkBuilder = reinterpret_cast<LootSlotLinkBuilder_t>(
Offsets::FUN_LOOT_SLOT_LINK_BUILDER);
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
if (itemID == 0)
continue;
const uint32_t count = *reinterpret_cast<const uint32_t *>(entry + 8);
SlotItem item;
item.itemID = itemID;
item.count = count;
char linkBuf[0x400];
const uint32_t slotArg = hasCoin ? static_cast<uint32_t>(slot + 1)
: static_cast<uint32_t>(slot);
const char *link = LinkBuilder(slotArg, linkBuf, sizeof linkBuf);
if (link != nullptr)
item.link = link;
out->items.push_back(std::move(item));
}
}
// === State-machine transitions ===
void StartLoot(uint64_t guid);
void TryStartNext();
void SendCloseLoot() {
auto Close = reinterpret_cast<CloseLootInner_t>(Offsets::FUN_CLOSE_LOOT_INNER);
Close(1, '\0', '\0');
}
void Complete() {
s_scan.state = State::Idle;
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
}
void TryStartNext() {
if (s_scan.queue.empty()) {
Complete();
return;
}
uint64_t guid = s_scan.queue.back();
s_scan.queue.pop_back();
StartLoot(guid);
}
void StartLoot(uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr) {
TryStartNext();
return;
}
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(s_scan.player, target, /*useDistanceCheck=*/0);
s_scan.currentGuid = guid;
s_scan.state = State::WaitingForResponse;
s_scan.ticksSinceTransition = 0;
}
// === Hooks ===
LootController_t s_lootController_o = nullptr;
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
// actively interleaving open/close cycles, signaled by
// `s_scan.suppressEvents`. The flag is raised by the controller hook
// just around the original-loot-controller call and the
// `SendCloseLoot` that follows, then dropped before
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
// event fire — and normal play's loot events — pass through.
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
if (s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
return;
}
s_fireEventNoArgs_o(eventID, 0);
}
// Intercepts the engine's loot-window state controller. Two paths:
//
// 1. **Scan-driven open** — the incoming `lootStructPtr` carries a
// GUID matching our expected one. Let the original run (so the
// slot table populates), then scrape, send a release (which
// will recursively re-enter via `FUN_0048F200` → close-path
// `FUN_LOOT_CONTROLLER(NULL, …)`), and advance the scan. Both
// the inner open and the recursive close fire their respective
// events through our `FireEventNoArgs_h`, which swallows them
// while `suppressEvents` is on.
//
// 2. **Anything else** — pass through unmodified. Normal loot
// interactions outside a scan are unaffected; the inner-close
// call generated by our scan's `SendCloseLoot` re-enters here
// with `arg1 == 0` and is also a passthrough (its
// suppression-flag check happens at the FireEvent hook layer).
void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
const bool scanOpenCall =
s_scan.state == State::WaitingForResponse &&
lootStructPtr != 0 &&
[&] {
// Read the GUID off the response struct (`+8` deref to a
// `{uint32 lo; uint32 hi}` block — same shape the original
// copies into `VAR_LOOT_GUID_LO/HI`).
auto *guidPtr = *reinterpret_cast<const uint32_t *const *>(
reinterpret_cast<const uint8_t *>(
static_cast<intptr_t>(lootStructPtr)) + 8);
if (guidPtr == nullptr) return false;
const uint64_t guid =
static_cast<uint64_t>(guidPtr[0]) |
(static_cast<uint64_t>(guidPtr[1]) << 32);
return guid == s_scan.currentGuid;
}();
if (scanOpenCall) {
s_scan.suppressEvents = true;
s_lootController_o(lootStructPtr, coin, unk);
LootEntry entry;
entry.guid = s_scan.currentGuid;
ScrapeCurrentLoot(&entry);
s_scan.results.push_back(std::move(entry));
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
// with `arg1 == 0`. That recursive call falls into the
// passthrough branch but still sees `suppressEvents == true`
// at the FireEvent layer, so the `LOOT_CLOSED` fire inside
// gets swallowed.
SendCloseLoot();
s_scan.suppressEvents = false;
TryStartNext();
return;
}
s_lootController_o(lootStructPtr, coin, unk);
}
const Game::HookAutoRegister _hookController{
Offsets::FUN_LOOT_CONTROLLER,
reinterpret_cast<void *>(&LootController_h),
reinterpret_cast<void **>(&s_lootController_o)};
const Game::HookAutoRegister _hookFireEvent{
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<void *>(&FireEventNoArgs_h),
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
// === Tick (timeout only) ===
void OnTick() {
if (s_scan.state == State::Idle)
return;
++s_scan.ticksSinceTransition;
if (s_scan.ticksSinceTransition >= kStepTimeoutTicks) {
// Controller hook never fired — engine guards rejected the
// send (player state / OOR), or the server dropped the
// packet. Skip this corpse and advance.
s_scan.suppressEvents = false;
TryStartNext();
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// === Enumeration ===
struct EnumCtx {
const void *player;
std::vector<uint64_t> *out;
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
if (!IsLootableUnit(obj))
return 1;
if (!InInteractRange(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
}
void BuildQueue(void *player) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
}
// === Lua bindings ===
int __fastcall Script_ScanNearbyLoot(void *L) {
if (s_scan.state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
s_scan.results.clear();
s_scan.player = player;
BuildQueue(player);
if (s_scan.queue.empty()) {
Complete();
Game::Lua::PushBool(L, true);
return 1;
}
TryStartNext();
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsScanInProgress(void *L) {
Game::Lua::PushBool(L, s_scan.state != State::Idle);
return 1;
}
int __fastcall Script_GetLastScanResults(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int outIdx = 1;
for (const auto &entry : s_scan.results) {
Game::Lua::PushNumber(L, static_cast<double>(outIdx++));
Game::Lua::NewTable(L);
char guidBuf[Guid::STRING_SIZE];
Game::Lua::SetFieldString(L, "guid",
Guid::FormatAsString(entry.guid, guidBuf,
sizeof guidBuf));
Game::Lua::SetFieldNumber(L, "coin", static_cast<double>(entry.coin));
Game::Lua::PushString(L, "items");
Game::Lua::NewTable(L);
int itemIdx = 1;
for (const auto &item : entry.items) {
Game::Lua::PushNumber(L, static_cast<double>(itemIdx++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(item.itemID));
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(item.count));
if (!item.link.empty())
Game::Lua::SetFieldString(L, "link", item.link.c_str());
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
&Script_ScanNearbyLoot);
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
&Script_IsScanInProgress);
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
&Script_GetLastScanResults);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Scan
+104
View File
@@ -0,0 +1,104 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include <cstdint>
namespace Loot::Unit {
namespace {
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
// `char` argument is padded to 4 stack bytes by the ABI.
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `C_Loot.LootUnit(guid)` — initiates a loot session against the unit
// identified by `guid` (a hex-string as returned by `UnitGUID` /
// `C_Loot.GetNearbyLootableUnits`). The engine handles release-prior-
// loot, packet construction, and dispatch; the loot window appears
// asynchronously via the regular `LOOT_OPENED` event once the server's
// `SMSG_LOOT_RESPONSE` lands.
//
// `useDistanceCheck` is hardcoded to `0` so the engine doesn't auto-
// walk the player toward a corpse that happens to be just out of
// range — which would be a surprise side-effect for an AoE-loot
// caller looping over `GetNearbyLootableUnits`. The server still
// enforces real range; OOR requests just don't get a response.
//
// Returns nothing. Silent no-op when:
// - The object manager is NULL (glue / character-select).
// - The GUID doesn't resolve to a visible `TYPEMASK_UNIT` object
// (target despawned, out of render range, wrong typemask).
// - The local player pointer can't be resolved.
// Errors only on Lua usage problems (missing arg, unparseable GUID).
int __fastcall Script_LootUnit(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid)");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint64_t guid = 0;
if (!Guid::Parse(guidStr, &guid) || guid == 0) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid) — unparseable GUID");
return 0;
}
// Object manager NULL pre-login; bail silently rather than
// crashing inside the resolver.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return 0;
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr)
return 0;
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
// engine uses for `__thiscall` calls — distinct from the global
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = ResolveUnitToken("player");
if (player == nullptr)
return 0;
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(player, target, /*useDistanceCheck=*/0);
return 0;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "LootUnit", &Script_LootUnit);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::Unit
+209
View File
@@ -0,0 +1,209 @@
// 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.
//
// Targeted single-item looting. Given a corpse GUID and a desired
// itemID, opens that corpse's loot window via the engine's standard
// `FUN_005DF2A0` path, finds the slot containing that itemID once the
// window populates, and dispatches `CMSG_LOOT_ITEM` directly through
// `FUN_005E1AD0`. The Lua-side `LootSlot` wrapper isn't used — see
// the `FUN_CMSG_LOOT_ITEM` comment in Offsets.h for why.
//
// Fast path when the window is already open for the requested target:
// skip the open round-trip and dispatch immediately. This makes
// "take items A, B, C from corpse X" a series of cheap calls — the
// first opens the window, the rest land in fast-path.
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
#include <cstdint>
namespace Loot::UnitItem {
namespace {
constexpr int kStepTimeoutTicks = 180;
enum class State {
Idle,
WaitingOpen,
};
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
// callee reads its arg at `[ebp+8]` (stack) and ends with `ret 4`.
// Mis-declaring as `__fastcall` makes MSVC skip the push but the
// callee still `ret 4`'s, drifting the caller's ESP by 4 bytes per
// call and corrupting the surrounding frame. The first symptom is a
// crash several frames later on an unrelated vtable lookup —
// exactly the EIP=0x00000A70-style faults we saw before this fix.
using SendLootItem_t = void(__stdcall *)(uint8_t slotByte);
State s_state = State::Idle;
uint64_t s_targetGuid = 0;
uint32_t s_desiredItemID = 0;
int s_ticksSinceTransition = 0;
uint64_t ReadLootTargetGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_HI);
return static_cast<uint64_t>(hi) << 32 | lo;
}
// Walks the currently-open loot window's slots looking for `itemID`.
// Returns the wire-protocol slot byte the server expects in
// `CMSG_LOOT_ITEM`, or `-1` if no slot matches. First match wins; if
// the same itemID appears twice, only the first is reachable through
// this function (caller can issue a second call after the first
// completes).
int FindWireSlotForItemID(uint32_t itemID) {
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t id = *reinterpret_cast<const uint32_t *>(entry);
if (id == 0)
continue;
if (id == itemID)
return static_cast<int>(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
}
return -1;
}
bool TakeItem(uint32_t itemID) {
const int slotByte = FindWireSlotForItemID(itemID);
if (slotByte < 0)
return false;
auto Send = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
Send(static_cast<uint8_t>(slotByte));
return true;
}
void Reset() {
s_state = State::Idle;
s_targetGuid = 0;
s_desiredItemID = 0;
}
void OnTick() {
if (s_state == State::Idle)
return;
++s_ticksSinceTransition;
if (s_state == State::WaitingOpen) {
if (ReadLootTargetGuid() == s_targetGuid) {
TakeItem(s_desiredItemID);
Reset();
return;
}
if (s_ticksSinceTransition >= kStepTimeoutTicks)
Reset();
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// `C_Loot.LootUnitItem(guid, itemID)` — takes a specific item from a
// specific corpse. If the corpse's loot window is already open, the
// item is dispatched synchronously and the function returns `true` if
// the slot was found, `false` if not. Otherwise the corpse's window is
// opened asynchronously, and once it populates the item is sent
// automatically. The async path returns `true` once the request is
// queued; there's no "did it succeed" event — observe the player's
// inventory or `LOOT_OPENED`/`ITEM_PUSH` events to confirm.
//
// Returns `false` immediately if:
// - args are missing or invalid types,
// - another `LootUnitItem` is mid-flight,
// - the world isn't loaded,
// - the target GUID doesn't resolve to a visible unit,
// - (sync path only) no slot in the currently-open window holds
// the requested itemID.
//
// The CMSG_LOOT_ITEM send bypasses the BoP-confirmation dialog the
// vanilla UI normally shows. Server still enforces all real
// permissions (loot rights, distance, master-loot rules); the
// client-side BoP-prompt is purely a courtesy that callers of this
// programmatic API have already opted out of by virtue of asking.
int __fastcall Script_LootUnitItem(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID)");
return 0;
}
uint64_t guid = 0;
if (!Guid::Parse(Game::Lua::ToString(L, 1), &guid) || guid == 0) {
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID) — invalid guid");
return 0;
}
const uint32_t itemID = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
if (s_state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
// Fast path: window already open for the requested target — dispatch
// CMSG_LOOT_ITEM synchronously. No waiting on `WorldTick`, no state
// transition needed.
if (ReadLootTargetGuid() == guid) {
Game::Lua::PushBool(L, TakeItem(itemID));
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (target == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
LootUnit(player, target, /*useDistanceCheck=*/0);
s_targetGuid = guid;
s_desiredItemID = itemID;
s_state = State::WaitingOpen;
s_ticksSinceTransition = 0;
Game::Lua::PushBool(L, true);
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "LootUnitItem",
&Script_LootUnitItem);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace Loot::UnitItem
+101
View File
@@ -0,0 +1,101 @@
// 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/>.
// `GetInboxItemLink(messageIndex[, attachmentIndex])` — returns
// `(link, itemID)` for the item attached to inbox message
// `messageIndex` (1-based). `link` is a basic itemID-only hyperlink
// (see note below). Vanilla supports one attachment per message; the
// optional 1-based `attachmentIndex` exists for modern-signature
// parity and is no-op for any value other than 1.
//
// `itemID` as a second return is a ClassicAPI extension on top of
// modern's link-only signature — saves callers a `string.match` to
// parse it back out of the link.
//
// **Basic link only** (`|cff...|Hitem:N:0:0:0|h[Name]|h|r`). Vanilla
// inbox entries DO store per-instance modifiers inline at `+0x12C..`
// (enchant, suffix factor, random property — same fields
// `Script_GameTooltip_SetInboxItem` copies onto the tooltip), but
// 3.3.5's `GetInboxItemLink` ignores them and returns the itemID-only
// link too (`FUN_0061E290(itemID)`). We match modern behavior — per-
// instance data only fully manifests when the player takes the item
// and the engine spawns a real CGItem; until then the link is
// definitionally itemID-bound.
//
// Returns nothing on out-of-range messageIndex, empty message slot,
// no attachment on the message, or attachmentIndex ≠ 1.
#include "Game.h"
#include "Offsets.h"
#include "item/Link.h"
#include <cstdint>
namespace Mail::InboxItemLink {
namespace {
int __fastcall Script_GetInboxItemLink(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: GetInboxItemLink(messageIndex[, attachmentIndex])");
return 0;
}
const int oneBasedMsg = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (oneBasedMsg < 1)
return 0;
if (Game::Lua::IsNumber(L, 2)) {
const int attach = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (attach != 1)
return 0;
}
const int msgIdx = oneBasedMsg - 1;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_INBOX_COUNT));
if (msgIdx >= count)
return 0;
// `VAR_INBOX_ENTRIES` is a pointer slot, not the array itself —
// one extra deref to reach the entry-pointer array.
auto **entries = *reinterpret_cast<const uint8_t ***>(
Offsets::VAR_INBOX_ENTRIES);
if (entries == nullptr)
return 0;
const uint8_t *entry = entries[msgIdx];
if (entry == nullptr)
return 0;
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_INBOX_ENTRY_ITEM_ID);
if (itemID == 0)
return 0;
char buf[256];
if (!Item::Link::BasicFromItemID(itemID, buf, sizeof buf))
return 0;
Game::Lua::PushString(L, buf);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetInboxItemLink",
&Script_GetInboxItemLink);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Mail::InboxItemLink
+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/>.
// `GetSendMailItemLink([attachmentIndex])` — returns
// `(link, itemID)` for the item currently attached to the outgoing-
// mail slot. `link` is the fully-decorated per-instance hyperlink
// (enchant / random suffix preserved). Modern WoW expanded mail to up
// to 12 attachments and added the index arg; vanilla 1.12 allows
// exactly one, so we accept the arg for signature parity but treat
// only index 1 as meaningful (any other index returns nothing).
//
// `itemID` as a second return is a ClassicAPI extension on top of
// modern's link-only signature — saves callers a `string.match` to
// parse it back out of the link.
//
// The attached item still exists as a live CGItem in the player's
// inventory until the mail is actually sent — the engine stores its
// GUID at `VAR_SEND_MAIL_ITEM_GUID_LO/HI`. We resolve the GUID via
// `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)` and feed the
// resulting CGItem to the standard `Item::Link::FromCGItem` builder,
// so enchant / random-suffix / unique-ID decoration matches what
// `GetContainerItemLink` would return for the same item back in
// the player's bag.
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Link.h"
#include <cstdint>
namespace Mail::SendMailItemLink {
namespace {
using GetObjectByGuid_t = void *(__fastcall *)(uint32_t typeMask, const char *debugMsg,
uint32_t guidLo, uint32_t guidHi,
int debugCode);
int __fastcall Script_GetSendMailItemLink(void *L) {
// Optional 1-based attachment index. Anything other than 1 is a
// no-op (vanilla is single-slot). Missing arg = treat as index 1.
if (Game::Lua::IsNumber(L, 1)) {
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (idx != 1)
return 0;
}
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_LO));
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_HI));
if (guidLo == 0 && guidHi == 0)
return 0;
auto fn = reinterpret_cast<GetObjectByGuid_t>(Offsets::FUN_GET_OBJECT_BY_GUID);
auto *cgItem = static_cast<const uint8_t *>(
fn(Offsets::OBJECT_TYPE_ITEM, nullptr, guidLo, guidHi, 0));
if (cgItem == nullptr)
return 0;
const char *link = Item::Link::FromCGItem(cgItem);
if (link == nullptr || *link == '\0')
return 0;
const int itemID = Item::ID::FromCGItem(cgItem);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSendMailItemLink",
&Script_GetSendMailItemLink);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Mail::SendMailItemLink
+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).
+66
View File
@@ -0,0 +1,66 @@
// 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/>.
// `IsLoggedIn()` — exposed on both Lua states (glue and in-game).
// Returns `true` once a character has been loaded *and identified*
// (GUID populated), `false` on glue, pre-load, and the brief window
// between struct allocation and character-data unpack.
//
// Check is two-step:
// 1. `[VAR_LOCAL_PLAYER_PTR]` non-NULL — the player aggregator
// struct has been allocated
// 2. `*(uint64*)(player + OFF_LOCAL_PLAYER_GUID)` non-zero — the
// character's GUID has been populated into the struct
//
// The pointer alone is too lax: the engine allocates the struct
// shortly before the character data lands, so there's a window where
// the pointer is non-NULL but the GUID is still zero. Most addons
// that check `IsLoggedIn` go straight to `UnitGUID("player")` next,
// and we don't want them to see GUID==nil right after IsLoggedIn
// reported true.
//
// 3.3.5 uses a single byte flag at `[0x00BD0793]` cleared on world
// entry — we couldn't pin down the analogous 1.12 flag (the engine
// path that signals event 253 wires through CVar-init machinery and
// doesn't map cleanly across the binary). The pointer+GUID check is
// the same effective signal: both flip together at the moment the
// player object is ready, and stay stable across zone transitions.
#include "Game.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::LoginStatus {
namespace {
int __fastcall Script_IsLoggedIn(void *L) {
Game::Lua::PushBool(L, Unit::Identity::PlayerGuid() != 0);
return 1;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("IsLoggedIn", &Script_IsLoggedIn);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("IsLoggedIn", &Script_IsLoggedIn);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
static const Game::GlueModuleAutoRegister _gluereg{&RegisterGlue};
} // namespace Player::LoginStatus
+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
+155 -1
View File
@@ -40,15 +40,156 @@ inline const uint8_t *Peek(uint32_t questID) {
}
// Field offsets within the data block returned by `Lookup`.
constexpr int OFF_TITLE = 0x9C;
//
// Derived from the engine's own `Script_GetQuestLog*` accessors at
// `0x004DF930..0x004E12B0`. Each accessor calls `_GetRecord` then
// reads from one of these offsets; the existing comment block on each
// constant identifies the source function. See CLAUDE.md's
// "Quest static-info cache" section for the full trace.
// Numeric metadata fields, in wire order from `SMSG_QUEST_QUERY_RESPONSE`.
//
// The cache layout mirrors the packet's field order — the deserializer
// at the SMSG handler unpacks 15 uint32 fields at +0x00..+0x38 before
// the reward/choice item arrays. Names and semantics cross-referenced
// from the in-binary serializer (`FUN_006DBCEC`) plus the canonical
// vanilla 1.12 packet structure documented by vmangos/cmangos emulator
// projects. Fields without a direct binary-side accessor (so just
// known from wire-format alignment) are marked **(hypothesis)** —
// they should be verified empirically before being relied on for
// gameplay logic.
//
// QuestId — uint32, echo of the questID this entry caches.
constexpr int OFF_QUEST_ID = 0x00;
// QuestMethod — uint32. Server-side classification (typically `2` for
// active-and-available, `0` for unavailable). Vanilla doesn't use
// this for gameplay; it's stored but no engine accessor reads it.
// **(hypothesis)**
constexpr int OFF_QUEST_METHOD = 0x04;
// Quest level — int32. Source: `FUN_004df340` (called by
// `Script_GetQuestLogTitle`) does `mov eax, [block + 0x08]` and
// pushes it as `level`.
constexpr int OFF_LEVEL = 0x08;
// ZoneOrSort — int32. The packet's `ZoneOrSort` field, used by the
// quest log UI to group entries under headers:
// - Positive = `AreaTable.dbc` zone areaID (e.g. 132 = Coldridge
// Valley, 12 = Elwynn Forest)
// - Negative = `-QuestSort.dbc` row (e.g. -263 = "Druid" sort)
// `FUN_004dee60` reads this directly and matches it against
// `VAR_QUEST_LOG_CATEGORY_TABLE` to find the collapsible header index.
constexpr int OFF_ZONE_OR_SORT = 0x0C;
// QuestInfo.dbc row — uint32 row index into the small DBC at
// `0x00C0D9CC` (records) / `0x00C0D9D0` (count). Each record is
// `{id@+0, char *name[locale]@+4}`; the localized name is the
// quest-type tag string ("Dungeon", "Raid", "Group", "PvP", "Elite"
// — modern WoW's `frequency`/`questTag`). Source: `FUN_004df2a0`.
constexpr int OFF_QUEST_INFO_ROW = 0x10;
// +0x14, +0x18, +0x1C, +0x20, +0x24 — five uint32 cache slots that
// SMSG_QUEST_QUERY_RESPONSE in vanilla 1.12 doesn't populate. The
// struct has them (the serializer at `0x006DBCEC` writes them as part
// of the WDB save / network shape), but the response handler leaves
// them zero-filled. Verified empirically across three diverse quests
// (race-restricted starter, dungeon, authored-timer delivery) — all
// five slots zero in every case.
//
// Wire-format docs (vmangos / cmangos emulator quest_template) name
// them SuggestedPlayers, LimitTime, RequiredRaces, RequiredSkill,
// RequiredSkillValue respectively, but the vanilla server *enforces*
// these constraints server-side and filters quests before broadcasting
// them, so the client never needs the data. Addons that want race /
// class / skill / timer info must source from an external scraped DB
// (pfQuest-style). Constants intentionally omitted — no use case.
//
// Signed money delta — int32. Positive = reward copper, negative =
// `-required` copper. The engine has two accessors that share this
// field with complementary sign:
// - `Script_GetQuestLogRewardMoney` returns max(0, +money)
// - `Script_GetQuestLogRequiredMoney` returns max(0, -money)
constexpr int OFF_MONEY_DELTA = 0x28;
// Level-cap reward money bonus — int32. Added to the reward when
// the player's level is ≥ 60. Source: `Script_GetQuestLogRewardMoney`
// does `cmp [player.unit + 0x70], 0x3C` (level >= 60) then
// `add edx, [block + 0x2C]`.
constexpr int OFF_LEVEL_CAP_BONUS_MONEY = 0x2C;
// Reward spellID — int32. 0 = no spell reward. Source:
// `Script_GetQuestLogRewardSpell` does `mov eax, [block + 0x30]` then
// resolves the SpellIcon path and locale-applied name via `Spell.dbc`.
// This is a spell *learned by the player* on completion (recipes,
// taught spells); not to be confused with `OFF_SRC_ITEM_ID` which
// is the item the questgiver hands over at accept-time.
constexpr int OFF_REWARD_SPELL_ID = 0x30;
// SrcItemId — itemID given to the player when the quest is accepted.
// 0 = no source item. The questgiver hands this item over so the
// player can complete the quest objective (e.g. "Read the Verdant
// Sigil" gives item 9580; player uses it, then turns the same item
// back in).
//
// Empirically derived (the offset was originally interpreted as
// `RewardSpellCast` from one emulator wire-order variant, but a
// real-world dump of quest 3120 stored `9580` here while the quest's
// own item objective also references item 9580 — consistent with
// SrcItemId semantics, not with a spell-cast-at-turn-in).
constexpr int OFF_SRC_ITEM_ID = 0x34;
// Quest flags — uint32. Bit definitions match modern WoW's
// `QUEST_FLAGS_*` enum, of which only bit 3 (`0x08` SHARABLE) is
// confirmed-tested by the engine (`Script_GetQuestLogPushable`'s
// `test byte ptr [block + 0x38], 0x08`). Other bits are presumed
// stored but unverified.
constexpr int OFF_QUEST_FLAGS = 0x38;
// Reward and choice item arrays — up to 4 reward and 6 choice
// itemIDs stored inline (4 bytes each). 0 in any slot means
// "no item in this slot". Used by `GetQuestLogItemLink` /
// `GetQuestLogItemID`.
//
// Each itemID array is paired with a parallel uint32 count array
// (the "how many of this item" reward). Layout in cache memory:
// +0x3C RewardItems[4]
// +0x4C RewardItemsCount[4]
// +0x5C ChoiceItems[6]
// +0x74 ChoiceItemsCount[6]
// Verified from `Script_GetQuestLogRewardInfo` (`fild [block + slot*4 + 0x4C]`
// for numItems) and `Script_GetQuestLogChoiceInfo` (`fild [block + slot*4 + 0x74]`).
constexpr int OFF_REWARD_ITEMS = 0x3C;
constexpr int OFF_REWARD_ITEMS_COUNT = 0x4C;
constexpr int OFF_CHOICE_ITEMS = 0x5C;
constexpr int OFF_CHOICE_ITEMS_COUNT = 0x74;
constexpr int REWARD_COUNT = 4;
constexpr int CHOICE_COUNT = 6;
// Point-of-interest marker — server-supplied map coordinate hint
// used by the questgiver objective panel to display "go here" arrows
// in modern WoW. Set on quests that have a designated location;
// 0/0/0 = no POI. Read sequentially by the serializer at
// `0x006DBE5B..0x006DBE8E` using mixed integer + float wire methods.
// **(hypothesis)**
constexpr int OFF_POI_MAP_ID = 0x8C; // uint32
constexpr int OFF_POI_X = 0x90; // float
constexpr int OFF_POI_Y = 0x94; // float
constexpr int OFF_POI_OPT = 0x98; // uint32 — display option, or doubles as title length
constexpr int OFF_TITLE = 0x9C;
// Inline narrative text — both locale-applied and printf-style
// format-expanded by the engine's variable substituter at
// `0x00506F70` (handles tokens like `$N` for player name, `$C` for
// class, etc.). Source: `Script_GetQuestLogQuestText` reads both,
// pushes `description` first then `objectives`.
//
// We deliberately expose the *raw* inline bytes, not the format-
// expanded version, because the substituter wants a 1024-byte
// scratch buffer and the substitutions (player name, class) belong
// in the addon layer anyway. Addons can run `string.gsub` if they
// need the runtime values, otherwise raw text is more useful for
// guides / databases.
constexpr int OFF_OBJECTIVES = 0x29C;
constexpr int OFF_DESCRIPTION = 0xA9C;
// Completion text — the "talk to me now" body shown on the
// quest-reward UI panel after objectives are met. 0x200 (512) bytes
// inline. Modern API exposes this as `GetQuestLogCompletionText()`.
// Confirmed by the cache copy at `0x00562E1F..0x00562E36` (a 0x200-byte
// memcpy from src+0x129C to dst+0x129C) and by the serializer at
// `0x006DBEBE` (`lea eax, [edi+0x129C]` followed by the string-writer
// call). Vanilla LeaderBoard reads from this offset for some override
// cases but the engine never directly exposes the text to Lua —
// `Script_GetQuestLogQuestText` returns description + objectives only.
constexpr int OFF_COMPLETION_TEXT = 0x129C;
// Objective arrays — four uint32[4] parallel slabs carrying
// SMSG_QUEST_QUERY_RESPONSE's RequiredNPCOrGo / Count / Item / Count
// fields verbatim. NPC-or-GO is **signed**: positive = creature
@@ -66,5 +207,18 @@ constexpr int OFF_REQUIRED_NPC_OR_GO_COUNT = 0x14AC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM = 0x14BC; // uint32[4]
constexpr int OFF_REQUIRED_ITEM_COUNT = 0x14CC; // uint32[4]
constexpr int OBJECTIVE_COUNT = 4;
// Per-objective override text — array of 4 inline char buffers,
// 256 (0x100) bytes per entry, indexed in objective declaration
// order (NPC/GO slots fill first, then item slots — same order the
// LeaderBoard accessor enumerates). Empty string = no override
// (engine auto-formats as `"<NPC name>: X/Y"`); otherwise the
// engine displays the buffer verbatim. Confirmed by the
// `shl ecx, 8; mov dl, [block + ecx + 0x14DC]` access pattern in
// `Script_GetQuestLogLeaderBoard` and by the four sequential reads
// in the packet serializer at `0x006DBF17..0x006DBF41` (0x14DC,
// 0x15DC, 0x16DC, 0x17DC).
constexpr int OFF_OBJECTIVE_TEXT = 0x14DC;
constexpr int OBJECTIVE_TEXT_STRIDE = 0x100;
constexpr int OBJECTIVE_TEXT_COUNT = 4;
} // namespace Quest::Cache
+4 -7
View File
@@ -31,12 +31,10 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
// `ret 8` cleans both args. We encode the questID into `userData` at
// request time so the callback knows which quest completed.
static void __stdcall QuestLoadCallback(void *userData, int success) {
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
if (slot < 0)
return;
const auto questID =
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
Event::Custom::Fire(slot, "%d%d", questID, static_cast<int>(success != 0));
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
questID, success != 0);
}
static void RequestAndMaybeNotify(uint32_t questID) {
@@ -47,9 +45,8 @@ static void RequestAndMaybeNotify(uint32_t questID) {
// invoking our callback. Fire the event ourselves so addons get the
// notification regardless of cache state, matching modern semantics.
if (wasCached) {
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", static_cast<int>(questID), 1);
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
static_cast<int>(questID), true);
}
}
+254
View File
@@ -0,0 +1,254 @@
// 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_QuestLog.GetQuestDetails(questID)` — single accessor that returns
// everything we know how to read from the quest static-info cache as a
// Lua table. Powers quest-helper-style addons that want one round-trip
// instead of N separate `C_QuestLog.GetTitleForQuestID` / etc. calls.
//
// Returns `nil` if the quest isn't cached. Caller should pair with
// `C_QuestLog.RequestLoadQuestByID` + listen for `QUEST_DATA_LOAD_RESULT`.
// All offsets read here come from the engine's own `Script_GetQuestLog*`
// accessors — see `Cache.h` for the per-field derivation.
#include "Game.h"
#include "Offsets.h"
#include "quest/Cache.h"
#include <cstdint>
namespace Quest::Details {
namespace {
// QuestInfo.dbc — 12-row table holding the localized quest-type tags
// ("Dungeon" / "Raid" / "Group" / "PvP" / "Elite"). Address pair from
// `docs/DBCs.md`.
constexpr uintptr_t VAR_QUESTINFO_DBC_RECORDS = 0x00C0D9CC;
constexpr uintptr_t VAR_QUESTINFO_DBC_COUNT = 0x00C0D9D0;
const char *LookupQuestType(uint32_t row) {
if (row == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(VAR_QUESTINFO_DBC_COUNT);
if (count <= 0 || row > static_cast<uint32_t>(count))
return nullptr;
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(
VAR_QUESTINFO_DBC_RECORDS);
if (!records)
return nullptr;
const uint8_t *record = records[row];
if (!record)
return nullptr;
const int localeIdx =
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (localeIdx < 0 || localeIdx > 8)
return nullptr;
auto *names = reinterpret_cast<const char *const *>(record + 4);
return names[localeIdx];
}
// Pushes an array of `{id=itemID, count=N}` tables for each non-zero
// reward slot. `ids[count]` and `counts[count]` are parallel arrays
// in the cache block. Leaves exactly one table on top of the stack.
void PushItemArray(void *L, const uint32_t *ids, const uint32_t *counts,
int count) {
Game::Lua::NewTable(L);
int next = 1;
for (int i = 0; i < count; ++i) {
if (ids[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(counts[i]));
Game::Lua::SetTable(L, -3);
}
}
// Per-objective override text — the 4-slot array at `+0x14DC` with
// 256-byte stride. Index in objective-declaration order (NPC/GO first,
// then item, same enumeration the LeaderBoard accessor walks).
// Returns empty string for slots past the cache's 4-entry capacity
// or for slots with an empty buffer (engine auto-formats those).
const char *OverrideTextFor(const uint8_t *data, int objectiveSlot) {
if (objectiveSlot < 0 || objectiveSlot >= Quest::Cache::OBJECTIVE_TEXT_COUNT)
return "";
return reinterpret_cast<const char *>(
data + Quest::Cache::OFF_OBJECTIVE_TEXT +
objectiveSlot * Quest::Cache::OBJECTIVE_TEXT_STRIDE);
}
void PushRequirements(void *L, const uint8_t *data) {
Game::Lua::NewTable(L);
int next = 1;
int overrideSlot = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *npcOrGoCount = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
const int32_t v = npcOrGo[i];
if (v == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", v > 0 ? "monster" : "object");
Game::Lua::SetFieldNumber(L, "id",
static_cast<double>(v > 0 ? v : -v));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(npcOrGoCount[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
auto *itemCounts = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM_COUNT);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (items[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(next++));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "kind", "item");
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(items[i]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(itemCounts[i]));
Game::Lua::SetFieldString(L, "text",
OverrideTextFor(data, overrideSlot++));
Game::Lua::SetTable(L, -3);
}
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
const auto u32 = [data](int off) {
return *reinterpret_cast<const uint32_t *>(data + off);
};
const auto i32 = [data](int off) {
return *reinterpret_cast<const int32_t *>(data + off);
};
const auto str = [data](int off) {
return reinterpret_cast<const char *>(data + off);
};
const auto f32 = [data](int off) {
return *reinterpret_cast<const float *>(data + off);
};
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID", static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", str(Quest::Cache::OFF_TITLE));
Game::Lua::SetFieldNumber(L, "level",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL)));
if (const char *tag =
LookupQuestType(u32(Quest::Cache::OFF_QUEST_INFO_ROW))) {
if (*tag != '\0')
Game::Lua::SetFieldString(L, "questType", tag);
}
// Race / class / skill / timer / suggested-players gates aren't
// here intentionally — vanilla 1.12's quest-query response leaves
// those cache slots (+0x14..+0x24) zero-filled; the server enforces
// those constraints and only ships quests to clients who could
// accept them. Addons that need that data must source it from an
// external scraped DB (pfQuest-style).
const int32_t money = i32(Quest::Cache::OFF_MONEY_DELTA);
Game::Lua::SetFieldNumber(L, "rewardMoney",
money > 0 ? static_cast<double>(money) : 0.0);
Game::Lua::SetFieldNumber(L, "requiredMoney",
money < 0 ? static_cast<double>(-money) : 0.0);
Game::Lua::SetFieldNumber(
L, "rewardMoneyAtMaxLevel",
static_cast<double>(i32(Quest::Cache::OFF_LEVEL_CAP_BONUS_MONEY)));
Game::Lua::SetFieldNumber(
L, "rewardSpellID",
static_cast<double>(i32(Quest::Cache::OFF_REWARD_SPELL_ID)));
Game::Lua::SetFieldNumber(
L, "srcItemID",
static_cast<double>(u32(Quest::Cache::OFF_SRC_ITEM_ID)));
const uint32_t flags = u32(Quest::Cache::OFF_QUEST_FLAGS);
Game::Lua::SetFieldNumber(L, "questFlags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isSharable", (flags & 0x08) != 0);
Game::Lua::SetFieldString(L, "description",
str(Quest::Cache::OFF_DESCRIPTION));
Game::Lua::SetFieldString(L, "objectives",
str(Quest::Cache::OFF_OBJECTIVES));
Game::Lua::SetFieldString(L, "completionText",
str(Quest::Cache::OFF_COMPLETION_TEXT));
if (const uint32_t mapID = u32(Quest::Cache::OFF_POI_MAP_ID)) {
Game::Lua::PushString(L, "poi");
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(mapID));
Game::Lua::SetFieldNumber(L, "x", static_cast<double>(f32(Quest::Cache::OFF_POI_X)));
Game::Lua::SetFieldNumber(L, "y", static_cast<double>(f32(Quest::Cache::OFF_POI_Y)));
Game::Lua::SetFieldNumber(L, "opt",
static_cast<double>(u32(Quest::Cache::OFF_POI_OPT)));
Game::Lua::SetTable(L, -3);
}
Game::Lua::PushString(L, "rewardItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REWARD_ITEMS_COUNT),
Quest::Cache::REWARD_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "choiceItems");
PushItemArray(L,
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS),
reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_CHOICE_ITEMS_COUNT),
Quest::Cache::CHOICE_COUNT);
Game::Lua::SetTable(L, -3);
Game::Lua::PushString(L, "requirements");
PushRequirements(L, data);
Game::Lua::SetTable(L, -3);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
}
} // namespace
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Quest::Details
+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
+59
View File
@@ -0,0 +1,59 @@
// 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/>.
// Suppress vanilla 1.12.1's "Your game interface files are corrupt"
// fatal error so the engine accepts the in-memory file modifications
// ClassicAPI makes via the file-read hook:
//
// - `src/bindings/Inject.cpp` splices into FrameXML's `Bindings.xml`
// - `src/addons/Embedded.cpp` surfaces the embedded `!!!ClassicAPI`
// addon
//
// Both diverge from what's on disk, so the cumulative hashes the
// engine computes against `Interface\FrameXML.sig` (global) and each
// addon entry's stored signature (per-addon) no longer match. The
// engine calls `FUN_00401560(10)` and tears down the process.
//
// We hook `FUN_00401560` (the fatal-error dispatcher) and drop calls
// with code `10`. Other codes pass through to the original handler
// unmolested — we don't want to mask legitimate fatal conditions
// (login failures, connection drops, etc., which use other codes).
//
// Modded clients (Octo + TLS-bootloader, SuperWoW-style) usually
// neutralize the integrity checks at startup; the engine never
// reaches `FUN_00401560(10)` there and our hook is a harmless no-op.
#include "Game.h"
#include "Offsets.h"
namespace Security::FrameXMLBypass {
namespace {
using FatalError_t = void(__fastcall *)(unsigned code);
FatalError_t FatalError_o = nullptr;
void __fastcall FatalError_h(unsigned code) {
if (code == 10)
return;
FatalError_o(code);
}
const Game::HookAutoRegister _hookreg{
Offsets::FUN_FATAL_ERROR,
reinterpret_cast<void *>(&FatalError_h),
reinterpret_cast<void **>(&FatalError_o)};
} // namespace
} // namespace Security::FrameXMLBypass
+227
View File
@@ -0,0 +1,227 @@
// 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.CastAtCursor(spellID)` — cast a ground-target spell at
// the player's current cursor world position, bypassing the manual
// click on the AoE reticle.
//
// Implementation chains the engine's existing primitives:
//
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
// target spell, the engine enters placement mode at this point
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
// fires immediately.
//
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
// placement at the resulting world coords via
// `FUN_COMMIT_PLACEMENT_COORDS`. Self-cancelling when the spell
// isn't ground-target (unit-target placement → cancel) or when
// the cursor isn't on terrain (over UI / off-screen → cancel).
//
// Returns `true` from `Resolve()` when we successfully placed at
// cursor; `false` otherwise (no placement was needed, or cursor was
// invalid). The cast itself proceeds either way — `Resolve()`'s
// return tells the caller whether the cursor-placement leg landed.
#include "AtCursor.h"
#include "Game.h"
#include "Offsets.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
namespace {
using RefreshCursorRaycast_t = void(__fastcall *)(void *clickInfo);
using CommitPlacementCoords_t = unsigned(__fastcall *)(const float *xyz);
using StopPlacement_t = void(__fastcall *)();
bool InWorld() {
// Player resolves only when in-world. Same gate other modules
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
// engine state during loading screens or glue.
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player") != nullptr;
}
void CancelPlacement() {
auto fn = reinterpret_cast<StopPlacement_t>(Offsets::FUN_STOP_PLACEMENT);
fn();
}
void Commit(const float coords[3]) {
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
commit(coords);
}
// 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)
return false; // no placement active — nothing to commit
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 placement happened.
CancelPlacement();
return false;
}
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 *>(
Offsets::VAR_WORLDFRAME_CLICK_INFO);
if (clickInfo == nullptr)
return false;
// Re-raycast at the current cursor screen position. Writes the
// new click type to `[clickInfo + OFF_CLICK_INFO_TYPE]` and the
// hit world coords to `[clickInfo + OFF_CLICK_INFO_COORDS]`.
auto refresh = reinterpret_cast<RefreshCursorRaycast_t>(
Offsets::FUN_REFRESH_CURSOR_RAYCAST);
refresh(clickInfo);
const uint32_t hitType = *reinterpret_cast<const uint32_t *>(
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_TYPE);
if (hitType != 1) {
// Cursor wasn't on terrain (over UI, off-screen, on a unit
// we didn't ask for). Modern `[@cursor]` would error; we
// chose silent cancel-and-return-false.
CancelPlacement();
return false;
}
const float *coords = reinterpret_cast<const float *>(
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_COORDS);
Commit(coords);
return true;
}
namespace {
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
} // 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
// input into the locale-resolved spell name internally, so the
// engine treats it just like `CastSpellByName("Blizzard")`.
char numBuf[16];
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
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);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "CastAtCursor",
&Script_C_Spell_CastAtCursor);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// Macro-parser pattern: tag macros that call
// `C_Spell.CastAtCursor(<spellID>)` with the spellID, so action-bar
// UIs highlight the slot the same way they do for `/cast SpellName`.
// The Lua call only accepts a numeric spellID, so the extractor just
// reads a numeric literal — no name resolution needed.
int ExtractCastAtCursorArg(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.CastAtCursor(", &ExtractCastAtCursorArg};
} // namespace
} // namespace Spell::AtCursor
+51
View File
@@ -0,0 +1,51 @@
// 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 Spell::AtCursor {
// Resolves the engine's in-flight ground-target placement at the
// player's current cursor world position. Intended to be called
// immediately after initiating a ground-target cast (spell or item)
// to skip the manual click. Decision tree:
//
// 1. No placement active → return false (nothing to do)
// 2. Placement is unit-target, not ground → cancel placement, return false
// 3. Cursor not pointed at terrain → cancel placement, return false
// 4. Cursor on terrain → commit at those world coords, return true
//
// Shared by `C_Spell.CastAtCursor` and `C_Item.UseAtCursor`. Safe to
// call from any in-world context — internally gates on a non-NULL
// 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
+27 -130
View File
@@ -42,6 +42,7 @@
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
@@ -208,141 +209,37 @@ void RegisterLuaFunctions() {
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// ---------------------------------------------------------------------------
// Macro parser hook
// Macro parser pattern
//
// Without this hook, a macro whose only cast statement is
// `CastSpellNoToggle("Shoot")` doesn't get tagged in the spell-state
// cache with the resolved spellID, because the engine's parser
// (`FUN_004EFE00`) only knows to look for `/cast` slash-aliases and
// `CastSpellByName("...")`. `IsAutoRepeatAction(macroSlot)` then has
// nothing to match against the active auto-repeat spell, so the slot
// fails to highlight in action-bar UIs (pfUI etc.).
//
// We hook the parser, let the original run, and if it didn't recognize
// any pattern (cache field stays 0), scan the body ourselves for our
// function name and use the engine's own name→spellID resolver to
// fill in the field.
// Tags macros containing `CastSpellNoToggle("Name")` with the
// resolved spellID, so `IsAutoRepeatAction(macroSlot)` reports
// correctly and action-bar UIs (pfUI etc.) highlight the slot for
// auto-repeat spells. `Spell::MacroPrimarySpell` owns the parser
// hook and the line walk; we just declare our `(prefix, extractor)`.
// ---------------------------------------------------------------------------
using MacroParse_t = void(__fastcall *)(int macroEntry);
MacroParse_t MacroParse_o = nullptr;
// Scan a macro body for `CastSpellNoToggle("<name>")` and return the
// resolved spellID, or 0 if no recognized invocation is present.
// Mirrors the engine parser's "first hit wins, line-at-a-time" walk
// order so behavior is consistent with how `/cast` and
// `CastSpellByName` are matched.
int ScanMacroBodyForOurPattern(const char *body) {
static constexpr char kPattern[] = "CastSpellNoToggle(";
static constexpr size_t kPatternLen = sizeof(kPattern) - 1;
const char *line = body;
while (*line != '\0') {
const char *eol = line;
while (*eol != '\0' && *eol != '\n')
++eol;
const char *p = line;
const char *end = eol;
while (p + kPatternLen <= end) {
if (std::memcmp(p, kPattern, kPatternLen) == 0) {
p += kPatternLen;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
if (p < end && *p == '"') {
++p;
const char *q = p;
while (q < end && *q != '"')
++q;
if (q < end) {
const size_t n = static_cast<size_t>(q - p);
if (n > 0 && n < 128) {
char name[128];
std::memcpy(name, p, n);
name[n] = '\0';
const int spellID = Spell::Arg::NameToSpellID(name);
if (spellID > 0)
return spellID;
}
}
}
break; // matched our prefix on this line; don't double-match
}
++p;
}
line = (*eol == '\n') ? eol + 1 : eol;
}
return 0;
int ExtractCastSpellNoToggleArg(const char *p, const char *end) {
while (p < end && (*p == ' ' || *p == '\t'))
++p;
if (p >= end || *p != '"')
return 0;
++p;
const char *q = p;
while (q < end && *q != '"')
++q;
if (q >= end)
return 0;
const size_t n = static_cast<size_t>(q - p);
if (n == 0 || n >= 128)
return 0;
char name[128];
std::memcpy(name, p, n);
name[n] = '\0';
return Spell::Arg::NameToSpellID(name);
}
// The vanilla parser's per-line stack buffer is 256 bytes. When a
// macro line exceeds that, the parser's local at `[EBP-4]` (the saved
// entry pointer) and the saved return address get clobbered with body
// bytes — observed in a crash with `entry@[EBP-4]` reading as ASCII
// `"onTi"` (fragment of `"expirationTime"` from a long `/run` line).
// The bound-check on the line-read function reads correctly in
// Ghidra, so the overflow path is somewhere we couldn't isolate from
// static analysis — we settle for a conservative pre-check that
// skips the original entirely when any line is long enough to risk
// the bug.
//
// 240 (not 256) leaves a safety margin: the parser writes a NUL
// terminator and may walk one or two bytes past in its loop logic,
// so we keep clear of the boundary. Macros that hit this branch
// can't have a `/cast` line anyway (`/cast SpellName(Rank N)` is
// well under 100 bytes), so losing the engine's `/cast` /
// `CastSpellByName` tagging is no loss. We still run our own
// `CastSpellNoToggle` pattern scan since it bound-checks its own
// extraction.
bool BodyHasLongLine(const char *body) {
if (body == nullptr)
return false;
constexpr int kMaxSafeLineBytes = 240;
int lineLen = 0;
for (; *body != '\0'; ++body) {
if (*body == '\n') {
lineLen = 0;
} else if (++lineLen > kMaxSafeLineBytes) {
return true;
}
}
return false;
}
void __fastcall MacroParse_h(int macroEntry) {
const char *body = reinterpret_cast<const char *>(
macroEntry + Offsets::OFF_MACRO_BODY);
int *cacheField = reinterpret_cast<int *>(
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
if (BodyHasLongLine(body)) {
// Skip the engine parser entirely — it overflows on lines
// longer than its 256-byte buffer. Mark the cache as "no
// recognized cast" (0); our own scan below may still tag it
// if it's a `CastSpellNoToggle("...")` macro.
*cacheField = 0;
const int spellID = ScanMacroBodyForOurPattern(body);
if (spellID > 0)
*cacheField = spellID;
return;
}
MacroParse_o(macroEntry);
if (*cacheField != 0)
return;
const int spellID = ScanMacroBodyForOurPattern(body);
if (spellID > 0)
*cacheField = spellID;
}
const Game::HookAutoRegister _hookreg{
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
reinterpret_cast<void *>(&MacroParse_h),
reinterpret_cast<void **>(&MacroParse_o)};
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
"CastSpellNoToggle(", &ExtractCastSpellNoToggleArg};
} // namespace

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