Compare commits

..
65 Commits
Author SHA1 Message Date
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
90 changed files with 8911 additions and 1312 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
+8 -3
View File
@@ -4,17 +4,22 @@
## Version: DEV
## Author: Brues
GlobalStrings.lua
locales\locales.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\String.lua
Util\Mixin.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
Util\FrameUtil.lua
Util\FormattingUtil.lua
Util\TimeUtil.lua
Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\Color.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\EventUtil.lua
-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,
};
@@ -0,0 +1,108 @@
-- Backport of FrameXML/FormattingUtil.lua (feasible subset) to vanilla
-- 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
-- defined in the locale layer (locales/enUS.lua), which loads first.
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD;
function FormatLargeNumber(amount)
amount = tostring(amount);
local newDisplay = "";
local strlen = string.len(amount);
-- Insert a separator before each group of three trailing digits.
for i = 4, strlen, 3 do
newDisplay = LARGE_NUMBER_SEPERATOR ..
string.sub(amount, -(i - 1), -(i - 3)) .. newDisplay;
end
-- Prepend everything before the first separator.
local lead = (math.mod(strlen, 3) == 0) and 3 or math.mod(strlen, 3);
newDisplay = string.sub(amount, 1, lead) .. newDisplay;
return newDisplay;
end
function FormatPercentage(percentage, roundToNearestInteger)
if roundToNearestInteger then
percentage = Round(percentage * 100);
else
percentage = percentage * 100;
end
return string.format(PERCENTAGE_STRING, percentage);
end
function FormatFraction(numerator, denominator)
return string.format(GENERIC_FRACTION_STRING, numerator, denominator);
end
function SplitTextIntoLines(text, delimiter)
local lines = {};
local startIndex = 1;
local foundIndex = string.find(text, delimiter);
while foundIndex do
table.insert(lines, string.sub(text, startIndex, foundIndex - 1));
startIndex = foundIndex + 2;
foundIndex = string.find(text, delimiter, startIndex);
end
if startIndex <= string.len(text) then
table.insert(lines, string.sub(text, startIndex));
end
return lines;
end
function SplitTextIntoHeaderAndNonHeader(text)
local foundIndex = string.find(text, "|n");
local len = string.len(text);
if not foundIndex then
return text; -- no newline: whole thing is a header
elseif len == 2 then
return nil; -- the string was just a newline
elseif foundIndex == 1 then
return string.sub(text, 3); -- leading newline: rest is a header
elseif foundIndex == len - 1 then
return string.sub(text, 1, foundIndex - 1); -- trailing newline
else
return string.sub(text, 1, foundIndex - 1), string.sub(text, foundIndex + 2);
end
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
-- reference is dropped since texture markup doesn't render in 1.12. The
-- checkGoldThreshold arg is accepted for signature compatibility but not
-- applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
local copper = math.mod(money, COPPER_PER_SILVER);
local goldString = (separateThousands and FormatLargeNumber(gold) or tostring(gold)) ..
GOLD_AMOUNT_SYMBOL;
local silverString = silver .. SILVER_AMOUNT_SYMBOL;
local copperString = copper .. COPPER_AMOUNT_SYMBOL;
if showZeroAsGold and money == 0 then
return goldString;
end
local moneyString = "";
local separator = "";
if gold > 0 then
moneyString = goldString;
separator = " ";
end
if silver > 0 then
moneyString = moneyString .. separator .. silverString;
separator = " ";
end
if copper > 0 or moneyString == "" then
moneyString = moneyString .. separator .. copperString;
end
return moneyString;
end
+64
View File
@@ -0,0 +1,64 @@
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
--
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
FrameUtil = FrameUtil or {};
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
end
function FrameUtil.GetRootParent(frame)
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
end
-- Reparents while preserving the frame's render layering.
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
end
function DoesAncestryInclude(ancestry, frame)
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
end
function DoesAncestryIncludeAny(ancestry, frames)
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
end
+86
View File
@@ -0,0 +1,86 @@
-- Backport of FrameXML/PixelUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- PixelUtil snaps frame sizes/points to whole physical pixels so
-- borders and textures stay crisp at fractional UI scales. The table
-- ports verbatim — its only math deps (Round, Lerp,
-- ClampedPercentageBetween) all live in MathUtil, which loads first.
--
-- The one piece vanilla lacks is the native `GetPhysicalScreenSize()`
-- (added in 7.0). The ClassicAPI DLL provides it (see
-- src/screen/Info.cpp) by reading the engine's `gxResolution` CVar, so
-- it's a real global registered at engine boot — no Lua polyfill
-- needed here.
PixelUtil = {};
function PixelUtil.GetPixelToUIUnitFactor()
local physicalWidth, physicalHeight = GetPhysicalScreenSize();
return 768.0 / physicalHeight;
end
function PixelUtil.GetNearestPixelSize(uiUnitSize, layoutScale, minPixels)
if uiUnitSize == 0 and (not minPixels or minPixels == 0) then
return 0;
end
local uiUnitFactor = PixelUtil.GetPixelToUIUnitFactor();
local numPixels = Round((uiUnitSize * layoutScale) / uiUnitFactor);
if minPixels then
if uiUnitSize < 0.0 then
if numPixels > -minPixels then
numPixels = -minPixels;
end
else
if numPixels < minPixels then
numPixels = minPixels;
end
end
end
return numPixels * uiUnitFactor / layoutScale;
end
function PixelUtil.ConvertPixelsToUI(desiredPixels, layoutScale)
return PixelUtil.GetNearestPixelSize(desiredPixels, layoutScale);
end
function PixelUtil.ConvertPixelsToUIForRegion(desiredPixels, region)
return PixelUtil.GetNearestPixelSize(desiredPixels, region:GetEffectiveScale());
end
function PixelUtil.SetWidth(region, width, minPixels)
region:SetWidth(PixelUtil.GetNearestPixelSize(width, region:GetEffectiveScale(), minPixels));
end
function PixelUtil.SetHeight(region, height, minPixels)
region:SetHeight(PixelUtil.GetNearestPixelSize(height, region:GetEffectiveScale(), minPixels));
end
function PixelUtil.SetSize(region, width, height, minWidthPixels, minHeightPixels)
PixelUtil.SetWidth(region, width, minWidthPixels);
PixelUtil.SetHeight(region, height, minHeightPixels);
end
function PixelUtil.SetPoint(region, point, relativeTo, relativePoint, offsetX, offsetY, minOffsetXPixels, minOffsetYPixels)
region:SetPoint(point, relativeTo, relativePoint,
PixelUtil.GetNearestPixelSize(offsetX, region:GetEffectiveScale(), minOffsetXPixels),
PixelUtil.GetNearestPixelSize(offsetY, region:GetEffectiveScale(), minOffsetYPixels)
);
end
function PixelUtil.SetStatusBarValue(statusBar, value)
local width = statusBar:GetWidth();
if width and width > 0.0 then
local min, max = statusBar:GetMinMaxValues();
local percent = ClampedPercentageBetween(value, min, max);
if percent == 0.0 or percent == 1.0 then
statusBar:SetValue(value);
else
local numPixels = PixelUtil.GetNearestPixelSize(statusBar:GetWidth() * percent, statusBar:GetEffectiveScale());
local roundedValue = Lerp(min, max, numPixels / width);
statusBar:SetValue(roundedValue);
end
else
statusBar:SetValue(value);
end
end
@@ -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 =
+24 -6
View File
@@ -25,8 +25,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| 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` |
@@ -35,23 +36,26 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `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.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [NameCache](docs/API.md#namecache) | `C_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`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.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`, `CancelSpellByName`, `CastingInfo`, `CastSpellNoToggle`, `ChannelInfo`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange`, `UnitCastingInfo`, `UnitChannelInfo` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
| [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
@@ -98,6 +102,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") |
@@ -132,6 +137,19 @@ 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
+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)")
+1903 -731
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
+9 -1
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);
+31 -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.
+638 -1
View File
@@ -18,6 +18,18 @@ enum Offsets {
FUN_INVALID_FUNCTION_PTR_CHECK = 0x42A320,
FUN_LOAD_SCRIPT_FUNCTIONS = 0x490250,
// Fatal-error dispatcher. `__fastcall(uint code)`. Writes `code`
// to `DAT_00882738` and chains into the process-teardown path;
// `FUN_00403BC0` reads the code at exit and shows a localized
// popup keyed off it. Code `10` = "interface files are corrupt"
// — the engine fires this from FrameXML's `FUN_0048FBF0` hash
// mismatch and per-addon `FUN_0051F240` hash mismatch (plus
// other call sites we haven't pinned down by byte pattern).
// Hooked by `Security::FrameXMLBypass` to suppress code 10 so
// our in-memory file modifications (Bindings.xml splice +
// embedded `!!!ClassicAPI` addon) don't trigger termination.
FUN_FATAL_ERROR = 0x00401560,
// Master glue Lua init — clean linear caller of all 5 glue batch
// trampolines (60 main + 11 char-select + 24 char-create + 10 realm
// + 4 frame globals = 109 total). Body: 0x0046ABB0..0x0046ABD2,
@@ -274,7 +286,10 @@ enum Offsets {
// holds the local player's 64-bit GUID, and the visible-object
// iterator at FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS walks its
// +0xAC list. Useful for "is this GUID me?" checks without
// round-tripping through Lua.
// round-tripping through Lua. Also a NULL-check proxy for "is
// the object manager initialised" — engine iterators deref this
// unconditionally on entry, so callers must skip on the glue /
// character-select screen.
VAR_LOCAL_PLAYER_PTR = 0x00B41414,
OFF_LOCAL_PLAYER_GUID = 0xC0,
// `__fastcall(ecx = const char *token) → uint64_t GUID` — the
@@ -390,6 +405,23 @@ enum Offsets {
// descriptor at +0x114 — these are sibling classes under CGObject
// with class-specific descriptor offsets.
OFF_UNIT_DESCRIPTOR = 0x110,
// CGCreature client-side creature-data cache row. Populated by the
// engine when an NPC GUID becomes visible (the same row the
// `CreatureCache` SMSG fills with the templated NPC's name,
// subname, etc.). NULL for unsynced units or for player CGUnits
// (players don't have a row in this cache — their name comes from
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
//
// Layout of the cache row:
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
@@ -595,6 +627,37 @@ enum Offsets {
// tick count in EAX).
FUN_OS_TICKCOUNT_MS = 0x0042B790,
// Effective cast time (ms) for a spell, fully resolved:
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/,
// int flag /*0 = clamp negatives to 0*/)`. Computes
// SpellCastTimes base + level scaling, applies the cast-time
// SpellMod (op 10, via FUN_006e6af0), then multiplies by the
// caster's cast-speed/haste mod (descriptor +0x22c). This is the
// exact helper the engine's own cast-start path (FUN_006ec910)
// uses for the cast-bar end time, so reading it keeps our
// UnitCastingInfo end time in lockstep with the engine. 0 for
// instant / no-cast-time spells.
FUN_GET_CAST_TIME = 0x006E3340,
// Effective power cost (mana/rage/energy/…) for a spell:
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/)`.
// Computes ManaCost base + per-level + ManaCostPercent-of-resource,
// applies the descriptor power-cost modifiers, then the cost
// SpellMod (op 0xE / 14). 0xFFFFFFFF on invalid input / no player.
// Read by C_Spell.GetSpellPowerCost (the full helper, like the cast
// time one — beats replicating op 14 via Spell::Mod since it also
// folds in the descriptor cost mods).
FUN_GET_SPELL_COST = 0x006E31B0,
// Effective spell/channel duration (ms):
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
// base + level scaling (capped at the row's max), then the duration
// SpellMod (op 1). Same `unit` selector as the cast-time/cost helpers.
// Used for UnitChannelInfo's channel end time. Note: takes the spell
// RECORD pointer, not a spellID.
FUN_GET_SPELL_DURATION = 0x006EA000,
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
@@ -1056,6 +1119,26 @@ enum Offsets {
// With `requestIfMissing=false`, returns NULL if not in cache (no server
// round-trip) — exactly the "instant" semantics we want.
FUN_DBCACHE_ITEMSTATS_GET_RECORD = 0x0055BA30,
// Item-cache response handler — the function called from
// `0x00555140` (success path for `SMSG_ITEM_QUERY_SINGLE_RESPONSE`)
// and `0x00555160` (failure path). Parses one or more itemIDs out
// of the packet, looks up (or creates) the cache entry per ID,
// copies the item data block via `FUN_007c9640` to `entry+0x18`,
// sets `[entry+0x1F0]=1` (the loaded byte), then walks the entry's
// pending callback list at `[entry+0x1FC]` and invokes each with
// `(userData, success)`. High-bit-on itemID in the packet signals
// a per-item failure for the batch.
//
// We hook this to observe any item-cache fill the engine performs
// — covers the engine's natural inventory prefetch and any
// addon-triggered queries without having to register our own
// engine callbacks. Lets `RequestLoadItemData` be purely passive:
// we track itemIDs, the engine (or whoever) issues the query, we
// see the fill via this hook and fire `ITEM_DATA_LOAD_RESULT`.
//
// Signature: `__thiscall(void *cache, void *packetReader, int flag)`.
// `cache` is the same `VAR_ITEMDB_CACHE` we already use.
FUN_ITEMSTATS_CACHE_RESPONSE = 0x0055BDB0,
// ItemStats_C field offsets we read. Full struct layout in
// VanillaHelpers's `Game.h` (`struct ItemStats_C`); we only need these.
OFF_ITEMSTATS_CLASS = 0x00,
@@ -1170,6 +1253,10 @@ enum Offsets {
// always at index 0 (some Turtle WoW custom items put it later).
OFF_ITEMSTATS_SPELL_ID = 0x11C, // u32[5] — spell ID per slot
OFF_ITEMSTATS_SPELL_TRIGGER = 0x130, // u32[5] — trigger code per slot
OFF_ITEMSTATS_SPELL_CHARGES = 0x144, // i32[5] — charges per slot (-1 = infinite)
OFF_ITEMSTATS_SPELL_COOLDOWN = 0x158, // u32[5] — cooldown ms per slot
OFF_ITEMSTATS_SPELL_CATEGORY = 0x16C, // u32[5] — category (groups shared cooldowns)
OFF_ITEMSTATS_SPELL_CATEGORY_CD = 0x180, // u32[5] — category cooldown ms
ITEMSTATS_SPELL_SLOT_COUNT = 5,
ITEM_SPELLTRIGGER_ON_USE = 0,
@@ -1181,6 +1268,63 @@ enum Offsets {
// members in VanillaHelpers's `ItemStats_C` definition.
OFF_ITEMSTATS_MAX_COUNT = 0x5C,
// Remaining `ItemStats_C` fields used by `C_Item.GetItemData(ByID)`.
// Offsets derived from VanillaHelpers's `struct ItemStats_C`
// definition (`C:\Git\VanillaHelpers\src\Game.h`), sized to match
// the verified anchors above (`+0x60 m_stackable`, `+0x1D0
// m_bagFamily`, `+0x1AC m_lockID`, etc.). Block follows the same
// ordering as the struct so it's easy to cross-reference.
OFF_ITEMSTATS_BUY_PRICE = 0x24, // u32
OFF_ITEMSTATS_ALLOWABLE_CLASS = 0x30, // u32 — class bitmask, -1 = all
OFF_ITEMSTATS_ALLOWABLE_RACE = 0x34, // u32 — race bitmask, -1 = all
OFF_ITEMSTATS_REQUIRED_LEVEL = 0x3C, // u32
OFF_ITEMSTATS_REQUIRED_SKILL = 0x40, // u32 — SkillLine.dbc row
OFF_ITEMSTATS_REQUIRED_SKILL_RANK = 0x44, // u32
OFF_ITEMSTATS_REQUIRED_SPELL = 0x48, // u32 — Spell.dbc row
OFF_ITEMSTATS_REQUIRED_HONOR_RANK = 0x4C, // u32
OFF_ITEMSTATS_REQUIRED_CITY_RANK = 0x50, // u32
OFF_ITEMSTATS_REQUIRED_FACTION = 0x54, // u32 — Faction.dbc row
OFF_ITEMSTATS_REQUIRED_FACTION_RANK = 0x58, // u32 — Friendly/Honored/etc.
OFF_ITEMSTATS_STAT_TYPE = 0x68, // u32[10] — ItemModType enum per slot
OFF_ITEMSTATS_STAT_VALUE = 0x90, // i32[10] — magnitude per slot
ITEMSTATS_STAT_SLOT_COUNT = 10,
OFF_ITEMSTATS_DAMAGE_MIN = 0xB8, // f32[5]
OFF_ITEMSTATS_DAMAGE_MAX = 0xCC, // f32[5]
OFF_ITEMSTATS_DAMAGE_TYPE = 0xE0, // u32[5] — schools per damage slot
ITEMSTATS_DAMAGE_SLOT_COUNT = 5,
OFF_ITEMSTATS_ARMOR = 0xF4, // u32
OFF_ITEMSTATS_RES_HOLY = 0xF8, // u32 (six resists contiguous)
OFF_ITEMSTATS_RES_FIRE = 0xFC,
OFF_ITEMSTATS_RES_NATURE = 0x100,
OFF_ITEMSTATS_RES_FROST = 0x104,
OFF_ITEMSTATS_RES_SHADOW = 0x108,
OFF_ITEMSTATS_RES_ARCANE = 0x10C,
OFF_ITEMSTATS_DELAY = 0x110, // u32 — weapon swing time (ms)
OFF_ITEMSTATS_AMMO_TYPE = 0x114, // u32 — ammo subclass for ranged
OFF_ITEMSTATS_RANGED_MOD_RANGE = 0x118, // f32 — range modifier on bows/guns
OFF_ITEMSTATS_BONDING = 0x194, // u32 — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest
OFF_ITEMSTATS_DESCRIPTION = 0x198, // char * — flavor text (locale-applied)
OFF_ITEMSTATS_PAGE_TEXT = 0x19C, // u32 — PageText.dbc row (readable books)
OFF_ITEMSTATS_LANGUAGE_ID = 0x1A0, // u32 — language for book text
OFF_ITEMSTATS_PAGE_MATERIAL = 0x1A4, // u32 — book material
OFF_ITEMSTATS_START_QUEST = 0x1A8, // u32 — questID started by right-click
OFF_ITEMSTATS_MATERIAL = 0x1B0, // i32 — material type
OFF_ITEMSTATS_SHEATH = 0x1B4, // u32 — weapon sheath style
OFF_ITEMSTATS_RANDOM_PROPERTY = 0x1B8, // i32 — random property template
OFF_ITEMSTATS_BLOCK = 0x1BC, // u32 — shield block value
OFF_ITEMSTATS_MAX_DURABILITY = 0x1C4, // u32
OFF_ITEMSTATS_AREA = 0x1C8, // u32 — bound area (AreaTable.dbc)
OFF_ITEMSTATS_MAP = 0x1CC, // u32 — bound map (Map.dbc)
// `m_flags` bits we surface as decoded booleans. The OPENABLE bit
// (0x4) is verified by the tooltip-builder's reader at 0x0052E323;
// the others are documented in the standard 1.12 protocol
// (CMaNGOS / TrinityCore `ITEM_FLAG_*`) and exposed as-is. Add
// verification anchors as consumers materialize.
ITEM_FLAG_CONJURED = 0x2,
ITEM_FLAG_LOOTABLE = 0x10,
ITEM_FLAG_WRAPPER = 0x200,
// `Spell.dbc` `Name[9]` field at record +0x1E0 (9-locale string
// array, indexed by `[VAR_LOCALE_INDEX]`). Used by `GetItemSpell`
// to push the spell name half of its return.
@@ -1304,6 +1448,10 @@ enum Offsets {
VAR_CHRCLASSES_COUNT = 0x00C0DEF8,
OFF_CHRCLASSES_NAMES = 0x14,
OFF_CHRCLASSES_FILENAME = 0x38,
// SpellFamilyName for the class — the gate the SpellMod system
// matches a spell's OFF_SPELL_RECORD_FAMILY_NAME against. Read by
// FUN_006e6ca0 (`DAT_00cecaac = ChrClasses[classID] + 0x3c`).
OFF_CHRCLASSES_SPELL_FAMILY = 0x3C,
// ChrRaces.dbc — standard 5-DWORD class shape at 0x00C0DED8,
// records-pointer at +0x08, count at +0x0C. 29 columns,
@@ -1488,6 +1636,35 @@ enum Offsets {
VAR_SKILL_LINE_COUNT = 0x00C0D930,
OFF_SKILL_LINE_NAME = 0x0C,
// Player's known-skills table — separate from the DBC. Populated
// by the engine on login from server data; entries are pointers
// to per-skill records. Each record's `+0x00` is the
// `SkillLine.dbc` row (or `0` for header / category rows), and
// `+0x10` is the current skill rank (other rank/modifier fields
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
// which walks this array indexed by the Lua arg.
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
// proficiency in subclass N of that item class — i.e.
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
// mail=subclass 3).
//
// Populated by SMSG_SET_PROFICIENCY's handler at
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
// verbatim from the packet. Same source the engine itself uses
// for equip eligibility (the tooltip "Mail" red-text logic
// and the cursor-drop-on-paperdoll check both read this). Note:
// covers **weapons too** (class 2), making it strictly more
// general than walking the per-skill-line table for individual
// armor proficiencies.
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
// Spell.dbc `BaseLevel` — the level a spell becomes available
// (trainer offers it, quest reward grants it, etc.). Distinct
// from `SpellLevel` at +0x74 which is the effective level used
@@ -1662,6 +1839,10 @@ enum Offsets {
// Note: the bit's MEANING is "can't toggle" — `canToggleAtWar` is
// its inverse, NOT its value.
REP_SLOT_FLAG_PEACE_FORCED = 0x10,
// INACTIVE flag — set by `Script_SetFactionInactive` (the "Inactive"
// collapsible category in FactionFrame). Toggles via
// `FUN_FACTION_SET_INACTIVE`; same byte as AT_WAR and PEACE_FORCED.
REP_SLOT_FLAG_INACTIVE = 0x20,
MAX_REP_SLOTS = 64,
// Static reaction-band threshold tables, indexed by reaction band
@@ -1681,6 +1862,81 @@ enum Offsets {
// against `FUN_004D5620(slot)`'s return.
OFF_CGPLAYER_INFO_WATCHED_REP_LIST_ID = 0x10C4,
// `FactionSetAtWar(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. The sole worker behind
// `Script_FactionToggleAtWar` (`0x004D6950`); the script wrapper just
// reads the current at-war flag, negates it, and tail-calls here.
//
// Validates (only when turning OFF) that current standing ≥ -3000,
// resolves factionID → repListID, bails if the player is in a loot
// session (UNIT_FIELD_FLAGS bit `0x80000`, error code `0x1A1`),
// mutates the rep-slot flag byte (`AT_WAR` gated by `PEACE_FORCED`),
// and sends `CMSG_SET_FACTION_ATWAR` (opcode `0x125`).
//
// **Does NOT fire `UNIT_FACTION`** — a vanilla engine omission. 3.3.5
// added a `FUN_0071F8F0(player, 0)` call here, whose inner
// `FUN_0060BF10(playerGUID, UNIT_FACTION_id)` dispatches the event
// for every unit token referencing the player. We polyfill by
// hooking this function and firing `UNIT_FACTION("player")` post-
// original (see `src/faction/UnitFactionPolyfill.cpp`).
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
// (`0x004D6A00`, clears it) — both script wrappers tail-call here.
//
// Resolves factionID → repListID via `FUN_004D5600`, mutates the
// rep-slot flag byte (sets/clears `INACTIVE` = `0x20`), writes via
// `FUN_004D5FC0`, sends `CMSG_SET_FACTION_INACTIVE` (opcode `0x317`),
// then calls `FUN_004D5C40` (recompute displayed-list, fire
// `UPDATE_FACTION` event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — same engine omission as the at-
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires when the server force-changes the player's
// at-war state on a faction (e.g., a flag the player can't toggle
// changes server-side, or faction reset). Reads `(u32 repListID,
// u8 flags)`, mutates AT_WAR on the slot accordingly, writes via
// `FUN_004D5FC0`, then fires `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — 3.3.5 does (unconditionally
// after the byte write). Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_SET_FACTION_ATWAR = 0x004D56B0,
// SMSG_INITIALIZE_FACTIONS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once at login from the SMSG dispatcher. Reads
// a count, then loops `(u8 flags, i32 standing)` pairs into the
// 64-entry rep slot array: `FUN_004D5FC0(slot, flags)` +
// `FUN_004D6330(slot, standing, 0 /*notify*/)`. Trailing
// `FUN_004D5430` rebuilds the displayed-list snapshot, then fires
// `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — modern WoW does. Addons that
// observe UNIT_FACTION miss the initial sync entirely in vanilla.
// Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_INITIALIZE_FACTIONS = 0x004D5640,
// SMSG_SET_FACTION_STANDINGS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once per server-pushed rep change (kills,
// quest turn-ins, etc.). Reads count, loops `(flags, standing)`
// pairs, calling `FUN_004D6330(slot, standing, 1 /*notify*/)` which
// routes through `FUN_REPUTATION_FIRE_NOTIFY` for the
// `CHAT_MSG_COMBAT_FACTION_CHANGE` dispatch.
//
// Also mutates AT_WAR / VISIBLE flag bits as standing crosses
// thresholds (e.g., recovering to -2999 clears the force-set AT_WAR
// from "below Hated"). **Vanilla doesn't fire `UNIT_FACTION` for
// these side-effect flag flips**; modern 3.3.5 does. Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp` — snapshots the 64-entry
// flag-byte array before/after and fires a single
// `UNIT_FACTION("player")` if any bit changed.
FUN_SMSG_SET_FACTION_STANDINGS = 0x004D5760,
// `__fastcall(ecx = factionID, edx = signedDelta)`. This is the
// engine's "reputation changed, fire the chat event" notify
// helper — it formats the localized chat message and dispatches
@@ -2012,6 +2268,67 @@ enum Offsets {
// to cast next", so we check this slot too.
VAR_QUEUED_CAST_SPELL = 0x00CECAA8,
// Spell-placement state bitmask. Non-zero when the engine is
// waiting on user input for an in-progress cast — the AoE reticle,
// unit-target cursor, trade-target cursor, etc. Bits encode what
// kind of input is needed; the engine's click handlers read this
// to decide whether a click was relevant. Read by
// `Script_SpellIsTargeting` via `FUN_006E48A0` (returns
// `state != 0`). Bits the engine OR-sets via `FUN_006E5250` when
// entering placement (subset relevant to us):
// - `0x20` source world location needed
// - `0x40` dest world location needed
// The `0x60` mask therefore identifies "ground-target placement"
// (vs. unit-target, trade-target, etc.). See `Spell::AtCursor`.
VAR_SPELL_PLACEMENT_STATE = 0x00CECAC0,
SPELL_PLACEMENT_GROUND_MASK = 0x60,
// `FUN_006E60F0(float *xyz)` — commits the in-flight ground-target
// placement at the given world position. Reads three contiguous
// floats from `xyz`; checks the placement-state mask
// (`VAR_SPELL_PLACEMENT_STATE`) to decide whether this is a
// source-coord (`0x20`) or dest-coord (`0x40`) commit; clears the
// matching bit and stores the coords into engine globals which the
// cast-finalize path then reads. Self-gates when neither bit is
// set — safe no-op for unit-target placement. Called by the engine
// itself when a player left-clicks ground while the reticle is up.
FUN_COMMIT_PLACEMENT_COORDS = 0x006E60F0,
// `FUN_006E4900()` — cancels in-flight placement mode. Internally
// checks `VAR_SPELL_PLACEMENT_STATE != 0` and, if so, fires the
// cast-cancel path via `FUN_006E4940(0x1C)`. Safe to call when no
// placement is active (no-ops). Wraps the action behind
// `Script_SpellStopTargeting`.
FUN_STOP_PLACEMENT = 0x006E4900,
// `*VAR_WORLDFRAME_CLICK_INFO` is a pointer to the engine's
// CWorldFrame "click info" struct — the data block where the input
// dispatcher stashes "what is the cursor pointed at right now"
// after every mouse motion. Allocated at world enter, populated
// continuously by the rendering pipeline (viewport bounds and
// camera position at fixed offsets) and the OS message pump
// (cursor screen pos). NULL when not in world.
// - `+0x350` (uint32) click type after last raycast:
// `0` = no hit
// `1` = world / terrain hit
// `2` = unit / object hit
// - `+0x360..+0x368` (3 floats) hit world position (when type == 1)
VAR_WORLDFRAME_CLICK_INFO = 0x00B4B2BC,
OFF_CLICK_INFO_TYPE = 0x350,
OFF_CLICK_INFO_COORDS = 0x360,
// `FUN_00481F00(void *clickInfo)` — refreshes the cursor's "what
// am I pointed at?" state on demand. Internally reads cursor
// screen pos from input state at `[clickInfo+0xA0]+0x1118/+0x111C`,
// calls the screen→world ray builder at `FUN_004813B0` (uses
// viewport bounds at `[clickInfo+0x390..+0x39C]` plus camera
// position at `[clickInfo+0x65B8]+8/+0xC/+0x10`), then raycasts
// via `FUN_0069BFF0` (terrain + WMO intersection). Writes click
// type to `[clickInfo+0x350]` and hit coords to
// `[clickInfo+0x360..+0x368]`. Safe to call from arbitrary code
// as long as the player is in-world.
FUN_REFRESH_CURSOR_RAYCAST = 0x00481F00,
// `Script_CastSpellByName` — engine's Lua wrapper for the
// `CastSpellByName(name [, onSelf])` global. `int __fastcall(void *L)`
// — standard Lua C function ABI. Reads `name` from stack[1] (string)
@@ -2021,6 +2338,16 @@ enum Offsets {
// results). Callable directly from C++ — push args on stack, call.
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
// the spell name to a spellbook slot. For ground-target spells, the
// engine enters placement mode (sets `VAR_SPELL_PLACEMENT_STATE`)
// and returns; placement is resolved later by mouse-click or
// programmatically via `FUN_COMMIT_PLACEMENT_COORDS`. Passing (0, 0)
// for the GUID is the "no implicit target" form — engine falls back
// to the player's current selected target.
FUN_SPELL_CAST_DISPATCH = 0x004B3300,
// Macro "primary spell" parser — `__fastcall(MacroEntry *entry)`.
// Walks the macro body (stored at `entry + OFF_MACRO_BODY`) line
// by line, looking for `/cast <name>`, `/castsequence <name>`,
@@ -2089,6 +2416,18 @@ enum Offsets {
VAR_SPELL_RECORDS = 0x00C0D788, // SpellRecord *records[spellID]
VAR_SPELL_RECORD_COUNT = 0x00C0D78C, // max spellID
// SpellMechanic.dbc — maps a SpellMechanic ID to its localized
// name. Standard 5-DWORD DBC instance at 0x00C0D7BC; records ptr at
// +0x08, count at +0x0C (see docs/DBCs.md). Records are 1-based
// (records[mechanicID]); the engine only ever uses the table for
// existence/bounds checks (FUN_006e9ca0, FUN_00612df0, FUN_006e8eb0
// case 0x8d all do `records[id] != 0`), never reading the name — so
// the name offset below follows the canonical 2-column
// (ID + localized Name) DBC schema rather than an engine-reader anchor.
VAR_SPELLMECHANIC_RECORDS = 0x00C0D7C4, // SpellMechanicRecord *records[mechanicID]
VAR_SPELLMECHANIC_COUNT = 0x00C0D7C8, // max mechanic ID
OFF_SPELLMECHANIC_NAME = 0x04, // char *name[9], locale-indexed
// Spell.dbc School field — 0-based integer at record +0x04.
// Verified empirically against Fireball (133) → School=2 (Fire)
// and Frostbolt (116) → School=4 (Frost) on Octo 1.12.1. Vanilla
@@ -2114,6 +2453,35 @@ enum Offsets {
VAR_SPELL_CAST_TIMES_RECORDS = 0x00C0D878, // SpellCastTimes.dbc
VAR_SPELL_CAST_TIMES_COUNT = 0x00C0D87C,
// SpellRadius.dbc — AOE radius records, indexed by a spell's
// EffectRadiusIndex (see OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX). Record
// shape: { id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }.
// Verified by decompiling FUN_006e6350 (the engine's effective-radius
// helper), which reads `radiusRec[+4]` as the base radius and
// `radiusRec[+8] * casterLevel` as the per-level term. Read by
// GetSpellRadius (base radius only — no level scaling).
VAR_SPELL_RADIUS_RECORDS = 0x00C0D7B0,
VAR_SPELL_RADIUS_COUNT = 0x00C0D7B4,
OFF_SPELL_RADIUS_VALUE = 0x04, // base radius, f32
// SpellMod (talent / item) accumulators for the LOCAL PLAYER. Two
// parallel tables of int32, indexed [familyFlagBit][op]: flat adds at
// VAR_SPELLMOD_FLAT_TABLE, percent adds at VAR_SPELLMOD_PCT_TABLE.
// Per-bit stride is 0x74 (29 ops); the op is the dword index (op*4
// within a slot). A spell's per-effect value is modified by summing,
// over the bits set in its SpellFamilyFlags, flat[bit][op] and
// pct[bit][op], then `value = (value + flat) * (100 + pct) * 0.01`.
// Gated on the spell's SpellFamilyName matching the player's class
// family. Decompiled from FUN_006e6b30 / FUN_006e6bf0 (the engine's
// ApplySpellMod), which the radius helper FUN_006e6350 invokes with
// op 6. These are single global instances = the local player's mods
// (vanilla only tracks spell mods for the local player).
VAR_SPELLMOD_FLAT_TABLE = 0x00CEAD60,
VAR_SPELLMOD_PCT_TABLE = 0x00CECB30,
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
// names like "Magic", "Curse", "Disease", "Poison". Used by
// `Script_UnitDebuff` to fill its third return value. Standard
@@ -2253,6 +2621,55 @@ enum Offsets {
LUA_NEXT = 0x6F4450,
LUA_ERROR = 0x6F4940,
// `lua_xmove(from, to, n)` — moves the top `n` TValues from one
// thread to another. Decrements `from->top` by `n*16`, copies each
// 16-byte TValue, increments `to->top`. Needed by the `coroutine.*`
// trampolines to shuttle args/returns between the main state and a
// suspended coroutine.
LUA_X_MOVE = 0x6F2F80,
// `lua_newthread(L)` — does GC check, calls inner `luaE_newthread`
// at `0x006F6B10` to allocate a new state, pushes it onto L's stack
// with type tag 8 (LUA_TTHREAD). Ghidra reports the return as
// `void` (decompiler limitation — the inner call's eax flows
// through), so callers should retrieve the new state via
// `ToPointer(L, -1)` rather than relying on the return value.
LUA_NEW_THREAD = 0x6F3030,
// `lua_resume(L_co, nargs)` — runs a coroutine. Zero internal
// callers in the engine (coroutine library was stripped from the
// standard-library registration tables at build time); linker
// kept the symbol so it's callable from C. Goes through
// `luaD_rawrunprotected` at `0x006F5DAC` for setjmp/longjmp
// safety; inner static `resume` helper lives at `0x006F67B0`.
LUA_RESUME = 0x6F6620,
// `lua_yield(L, nresults)` — flags the current CallInfo to yield
// and returns -1. Sets a `0x10` bit on the CI->state field rather
// than writing `L->status` directly in this build. Zero internal
// callers (same reason as `lua_resume`). Errors with
// "attempt to yield across metamethod/C-call boundary" or
// "cannot yield a C function" when called from the wrong context.
LUA_YIELD = 0x6F6860,
// `lua_topointer(L, idx)` — returns the pointer value of a
// pointer-valued TValue: type 5 (table), 6 (function), 8 (thread)
// return `tvalue[2]` directly; type 2 (lightuserdata) returns its
// value; type 7 (userdata) returns the userdata payload (record +
// 0x10). Returns 0 for anything else. We use it as `lua_tothread`
// — for a known-thread TValue, returns the embedded `lua_State *`.
LUA_TO_POINTER = 0x6F3790,
// `luaL_argerror(L, narg, msg)` — formats "bad argument #%d to
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
// (Lua 5.0 only allows Lua-defined functions as coroutine bodies).
LUA_IS_C_FUNCTION = 0x6F34A0,
// `lua_tothread(L, idx)` — returns the embedded `lua_State *` for
// type-8 (LUA_TTHREAD) TValues, NULL for anything else. Used by
// the `coroutine` library to extract the coroutine pointer from a
// stack slot.
LUA_TO_THREAD = 0x6F3770,
// Global `lua_State *`. The engine keeps one main thread state here; we
// read it on demand in helpers that run outside a Lua callback (e.g.
// RegisterTableFunction during LoadScriptFunctions).
@@ -3030,6 +3447,174 @@ enum Offsets {
VAR_LOOT_LOOTABLE = 0x00B71BA0,
LOOT_SLOT_STRIDE = 0x1C,
LOOT_MAX_SLOTS = 0x10,
// Per-slot field offsets within a `VAR_LOOT_SLOTS + slot * 0x1C`
// entry. Decoded from `Script_GetLootSlotInfo` and the populator
// at `FUN_004C1CB0`:
// +0x00 itemID (Script_GetLootSlotInfo's first push)
// +0x04 displayInfoID (icon lookup via DBC index)
// +0x08 count (FUN_004C22E0 reads `[base+0x8]`)
// +0x0C ?
// +0x10 ?
// +0x14 wire-slot byte (the server-side slot index — what
// `CMSG_LOOT_ITEM` expects; differs from the local
// densely-packed index when the response had gaps)
OFF_LOOT_SLOT_COUNT = 0x08,
OFF_LOOT_SLOT_WIRE_INDEX = 0x14,
// `__fastcall(uint userFacingSlot, char *outBuf, int bufSize) →
// const char *outBuf` — builds the loot-slot hyperlink string
// (e.g. `|cff1eff00|Hitem:12345:0:0:0|h[Item Name]|h|r`) and
// copies it into `outBuf`. Returns the buffer pointer on success,
// `NULL` if no loot window is open, the slot is empty, or the
// item's cache record isn't loaded yet. Used by
// `Script_GetLootSlotLink` at `0x004C2D20`.
//
// `userFacingSlot` is the post-`__ftol`-decrement slot index — the
// same shape Lua's `LootSlot(slot)` produces internally after
// `dec eax`:
// - When coin is present (`VAR_LOOT_LOOTABLE != 0`), input `0`
// is the coin slot itself and returns NULL; subsequent
// internal items live at `userFacingSlot = internal_slot + 1`
// (the function dec's again inside).
// - When no coin, `userFacingSlot = internal_slot` directly.
// The internal `_GetRecord` call inside uses `callback = NULL`, so
// the link is a pure cache hit — no network request gets queued
// when the cache is cold, just a NULL return.
FUN_LOOT_SLOT_LINK_BUILDER = 0x004C2670,
// `__stdcall(uint8_t slotByte)` — sends `CMSG_LOOT_ITEM` (opcode
// `0x108`) carrying the wire-protocol slot byte for the currently-
// open loot window. Built directly from the engine's packet
// primitives — bypasses the `Script_LootSlot` Lua wrapper at
// `FUN_004C2790`, which routes through a `param_2 != 0`
// BoP-confirm-after-dialog branch that's of no use to us. The
// server cheerfully takes whatever slot you send for the current
// loot session; no client-side BoP confirmation gate.
//
// **Calling convention matters.** The callee reads its arg from
// `[ebp+8]` (stack) and ends with `ret 4`. Mis-typedef'ing as
// `__fastcall` produces a stack-corrupting caller — symptoms are
// delayed crashes on unrelated vtable reads.
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
// server-driven dynamic-state bitmask. Empirically verified by
// cross-referencing three `Script_*` accessors that all read
// bytes at `[m_objectFields + 0x224]`:
// - `Script_UnitIsTapped` (`0x00519C90`): `test [eax+0x224], 0x04`
// - `Script_UnitIsTappedByPlayer` (`0x00519D00`): `test [eax+0x224], 0x08`
// - `Script_UnitIsDead` (`0x00517AC0`): `[eax+0x224] >> 5 & 1`
// Bit values match CMaNGOS conventions: 0x01 LOOTABLE,
// 0x02 TRACK_UNIT, 0x04 TAPPED, 0x08 TAPPED_BY_PLAYER,
// 0x10 SPECIALINFO, 0x20 DEAD, 0x40 REFER_A_FRIEND_LINKED,
// 0x80 ZONE_OUT. The +0x224 offset is significantly later than
// CMaNGOS-doc'd vanilla (which puts DYNAMIC_FLAGS at field 0x46
// = +0x118) — yet another non-uniform shift in 1.12.1's actual
// UNIT_FIELD layout. Direct empirical read is the authority.
OFF_UNIT_FIELD_DYNAMIC_FLAGS = 0x224,
UNIT_DYNFLAG_LOOTABLE = 0x01,
UNIT_DYNFLAG_DEAD = 0x20,
// UNIT_FIELD_BOUNDINGRADIUS within m_objectFields — single
// float, the unit's interact-radius in world yards. Both
// `FUN_005EC110` (interact-state check) and `FUN_005DF130`
// (CMSG_LOOT sender) compute the interaction-range threshold
// as `target_radius + player_radius + INTERACT_REACH`, clamped
// to a floor of `MIN_INTERACT_RANGE`. Verified at `0x005EC1A4`
// where the function reads
// `[(target_m_objectFields) + 0x1F0]`.
OFF_UNIT_FIELD_BOUNDING_RADIUS = 0x1F0,
// `__thiscall(player, target, useDistanceCheck)` — engine's
// CMSG_LOOT sender for **dead mobs** (CGCreature_C with
// DYNFLAG_LOOTABLE set). Pre-checks: `FUN_005EC110` interact
// validity + `FUN_006003A0` lootability (target dead + bit 0 of
// `m_objectFields[+0x224]` set) + local player not in a state
// that forbids interaction. Releases any open loot window before
// sending the new request.
//
// `useDistanceCheck` is the auto-walk gate:
// - `1` (engine default): if target out of range, start
// walking toward it and *don't* send CMSG_LOOT. If in range,
// send normally.
// - `0`: skip the range check; send immediately. The server
// enforces server-side range, so OOR sends silently fail
// rather than walking the player toward distant corpses —
// the right pick for AoE-loot-style callers.
//
// Engine callsite at `FUN_0060FA20` (right-click world handler)
// resolves the target via
// `ClntObjMgrObjectPtr(TYPEMASK_UNIT, ..., guid)` first, same as
// we do.
//
// Distinct from the sibling sender at `0x005DF130`, which gates
// on `FUN_CGCORPSE_IS_LOOTABLE` (player corpses only) instead of
// the mob path. The CGCorpse helper is documented immediately
// below.
FUN_CMSG_LOOT_UNIT = 0x005DF2A0,
// `__fastcall(int loot_response_struct, undefined4 coin, int unk)`
// — master loot-window state controller. Called by the
// `SMSG_LOOT_RESPONSE` handler tail (via `FUN_0048F3A0`) and by
// `CloseLoot`-inner (via `FUN_0048F200(0, 0, 0)`). Two phases:
// - **Close prior**: if `VAR_LOOT_GUID_LO/HI != 0`, clears
// them, releases item-cache callbacks for the prior slots,
// fires `LOOT_CLOSED` (event id `0x10D`).
// - **Open new**: if `param_1 != 0`, copies the GUID from
// `[param_1 + 8]` into `VAR_LOOT_GUID_LO/HI`, zeroes
// `VAR_LOOT_SLOTS`, walks 16 slots populating itemID via
// `FUN_005EBC90(slot)` and sibling helpers, sets
// `VAR_LOOT_LOOTABLE = param_2` (coin), then fires
// `LOOT_OPENED` (event id `0x10B`).
// Both event fires go through `FUN_FIRE_EVENT_NO_ARGS` —
// intercepting that function lets a caller swallow the events
// without touching this one.
FUN_LOOT_CONTROLLER = 0x004C1CB0,
// `__fastcall(int eventID)` — engine's no-args event dispatcher,
// the lighter-weight sibling of `FUN_FIRE_EVENT` (variadic).
// Called from ~100 sites across the engine, including the
// `LOOT_OPENED` / `LOOT_CLOSED` fires inside
// `FUN_LOOT_CONTROLLER`. Hooking it lets us conditionally swallow
// specific event ids while leaving the rest untouched — the
// mechanism we use to do silent scan loops without flickering
// the `LootFrame`. Fan-in is wide but call frequency is
// event-driven (state changes only), not per-frame, so per-call
// overhead from a hooked predicate is negligible.
FUN_FIRE_EVENT_NO_ARGS = 0x00703E50,
// `__fastcall(int sendRelease, char earlyReturnOnGameObject,
// char showError)` — engine's "close the current loot window"
// inner. Behavior keyed off args:
// - `sendRelease` (ECX): if non-zero, builds and sends
// `CMSG_LOOT_RELEASE` (opcode `0x15F`) for the current loot
// target before clearing client state.
// - `earlyReturnOnGameObject` (EDX): if non-zero, bails early
// when the loot target is a game object that's been
// interacted with in a particular way (fishing-style?).
// - `showError` (stack): if non-zero, calls `FUN_00496720(0x87)`
// to display a "cannot close loot" error.
// Side effects: clears `[player + 0x1D28/+0x1D2C]` (the player's
// prior-interact GUID slots), then calls `FUN_004C1CB0(0, 0, 0)`
// which clears `VAR_LOOT_GUID_LO/HI`, zeroes `VAR_LOOT_SLOTS`,
// and fires the `LOOT_CLOSED` event (`0x10D`). Use
// `(1, 0, 0)` for a clean release + clear.
FUN_CLOSE_LOOT_INNER = 0x0048F200,
// `CGCorpse::IsLootable(this)` — `__fastcall(corpse) → uint`,
// tests bit 0 of `[corpse_m_objectFields + 0x78]`. Specific to
// `TYPEMASK_CORPSE` (player corpses) — `+0x78` of a CGCorpse_C's
// m_objectFields holds dynamic flags with bit 0 = LOOTABLE. The
// same offset in CGUnit_C / CGPlayer_C's m_objectFields holds
// BYTES_0 (race / class / gender / power type), so this helper
// returns nonsense for live units (e.g. it returns 1 for Human
// players because race=1 has bit 0 = 1). Used by the engine's
// right-click-on-corpse handler at `FUN_005D6BF0` to gate
// CMSG_LOOT for player corpses, and from `FUN_005DF130` (one of
// two CMSG_LOOT senders) for the same corpse path. Don't reuse
// for live-unit lootability — use `OFF_UNIT_FIELD_DYNAMIC_FLAGS`
// bit 0 against a CGUnit instead.
FUN_CGCORPSE_IS_LOOTABLE = 0x005D6E20,
// Merchant inventory: flat array at `0xBDD118`, stride 0x1C,
// itemID at `entry+4`. The "flag" globals are actually the
@@ -3130,10 +3715,37 @@ enum Offsets {
// `+0x120`. (The engine's `Script_GetInboxItem` passes a per-
// entry completion callback at `0x004AF0A0` to GetRecord; for
// ID-only retrieval we skip the callback.)
//
// Entries also carry per-instance modifiers at `+0x12C`
// (enchantID), `+0x134` (suffix factor), `+0x138` (random
// property ID), etc. — `Script_GameTooltip_SetInboxItem` at
// `0x005354C0` copies them onto the tooltip object to render a
// fully-decorated tooltip. We don't expose them in
// `GetInboxItemLink` because 3.3.5's same function only emits
// basic itemID-only links (`FUN_0061E290(itemID)`); per-instance
// data only fully manifests when the player takes the item and
// the engine spawns a real CGItem.
VAR_INBOX_ENTRIES = 0x00B6EF54,
VAR_INBOX_COUNT = 0x00B6EFC0,
OFF_INBOX_ENTRY_ITEM_ID = 0x120,
// SendMail attached item — the engine stores the attached item's
// 64-bit GUID at these globals when the player drops an item onto
// the SendMail slot (`SetSendMailItem` / `ClickSendMailItemButton`).
// Cleared to zero by `ClearSendMail` and on `MAIL_CLOSED`. Vanilla
// allows exactly one attachment per outgoing mail (modern WoW lifted
// this to 12+; we ignore the modern `attachmentIndex` arg since only
// index 1 is meaningful).
//
// The attached item still exists as a live CGItem in the player's
// inventory until the mail is actually sent, so the GUID resolves
// via `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)`. That lets
// `GetSendMailItemLink` return the full per-instance link (enchant
// / random suffix preserved) by feeding the resolved CGItem to
// `Item::Link::FromCGItem`.
VAR_SEND_MAIL_ITEM_GUID_LO = 0x00B6EF90,
VAR_SEND_MAIL_ITEM_GUID_HI = 0x00B6EF94,
// Craft window (engineering, alchemy, etc. before the "trade
// skill" overhaul). Same slot-indirection shape as inbox: the
// address holds a pointer to the heap-allocated entry-pointer
@@ -3174,6 +3786,31 @@ enum Offsets {
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
// radius. Read by GetSpellRadius.
OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX = 0x160, // int32[3]
// SpellFamily classification, used by the SpellMod system (see
// VAR_SPELLMOD_*). Verified from FUN_006e6b30: SpellFamilyName at
// +0x280 (gates whether the player's class mods apply), 64-bit
// SpellFamilyFlags at +0x284 (selects which familyFlagBit rows to
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
// Spell.dbc Mechanic field — the spell-level SpellMechanic ID (→
// SpellMechanic.dbc), 0 = none. Field 5 of the record
// (ID, School, Category, castUI, Dispel, Mechanic), which lands at
// +0x14 given Attributes (field 6) is verified at +0x18. Cross-
// confirmed by FUN_006e9ca0 (the mechanic-immunity check), which
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
OFF_SPELL_RECORD_MECHANIC = 0x14,
SPELL_AURA_MOD_SHAPESHIFT = 36,
SPELL_AURA_MOUNTED = 78,
+10 -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
+12 -1
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;
}
+11 -4
View File
@@ -110,6 +110,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 +124,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 →
+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
+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
+129 -38
View File
@@ -331,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);
@@ -341,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;
@@ -424,60 +439,136 @@ int __fastcall Script_UseEquipmentSet(void *L) {
}
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];
// GUID_IGNORED: leave whatever the player has in this slot
// as-is. Distinct from GUID_EMPTY (= 0), which means "this
// slot should be empty after the swap" — handled below.
if (g == GUID_IGNORED)
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (g == GUID_EMPTY) {
// Set wants this slot empty. If it already is, no-op.
// Otherwise unequip whatever's there into the next bag
// slot we reserved during the pre-loop snapshot. If
// we've exhausted the snapshot, silently skip — matches
// the modern API which surfaces no per-slot reason.
const uint8_t *equipped =
Item::Location::ResolveEquipmentSlot(targetSlot);
if (equipped == nullptr)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const FreeSlot &fs = freeSlots[nextFreeSlot++];
Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot);
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));
+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
+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
+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};
+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;
}
+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`
+18
View File
@@ -254,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;
+9 -3
View File
@@ -82,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
+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
+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);
}
+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);
}
}
+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
+288
View File
@@ -0,0 +1,288 @@
// 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.
//
// Scope (vanilla ceiling, confirmed against the binary — see TODO #68):
// - Player casts/channels: full timing.
// - Other units' CHANNELS: spellID/name/texture from the broadcast
// +0x228 field, but no times (we don't track per-remote-unit start).
// - Other units' regular CASTS: nil. Vanilla doesn't store them — the
// casting animation is driven by transient spell-visual objects, not
// a queryable cast record.
//
// 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 "spell/Lookup.h"
#include "tick/WorldTick.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");
}
} // 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)` — only the local player has regular-cast data
// in vanilla; any other unit returns nil.
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCastingInfo(\"unit\")");
return 0;
}
if (ArgIsPlayer(L))
return PushCastInfo(L, g_cast);
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: 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;
const int spellID = *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false);
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitCastingInfo", &Script_UnitCastingInfo);
Game::Lua::RegisterGlobalFunction("CastingInfo", &Script_CastingInfo);
Game::Lua::RegisterGlobalFunction("UnitChannelInfo", &Script_UnitChannelInfo);
Game::Lua::RegisterGlobalFunction("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
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
// Owns the hook on `FUN_MACRO_PARSE_PRIMARY_SPELL` and dispatches to
// feature-module-registered `(prefix, extractor)` pairs. The engine's
// parser only recognizes `/cast SpellName` and `CastSpellByName(...)`
// — addons that define new cast wrappers (`CastSpellNoToggle`,
// `C_Spell.CastAtCursor`, etc.) need their macro slots tagged with
// the resolved spellID too, otherwise action-bar UIs lose
// auto-repeat highlight and cooldown overlay.
//
// Each feature module owns an extractor that knows how to read the
// arg shape for its specific function (quoted string, numeric
// literal, etc.). Patterns auto-register at static-init via
// `PatternAutoRegister`. The hook walks each macro line ONCE,
// checking every registered prefix at every position; on a prefix
// match it calls the extractor to resolve a spellID.
//
// Long-line overflow guard: the vanilla parser has a 256-byte
// per-line stack buffer that overflows on `/run` lines longer than
// that. We pre-check and skip the original entirely when any line
// exceeds the safe threshold — the original's tagging is lost in
// that case (no `/cast` on a 240+ byte line anyway) but our
// patterns still run since they bound-check their own extraction.
#include "MacroPrimarySpell.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Spell::MacroPrimarySpell {
namespace {
PatternAutoRegister *g_patternHead = nullptr;
using MacroParse_t = void(__fastcall *)(int macroEntry);
MacroParse_t MacroParse_o = nullptr;
// Vanilla's parser uses a 256-byte stack buffer per line; bytes past
// the buffer clobber the saved entry pointer at `[EBP-4]` and the
// return address. 240 leaves a safety margin.
constexpr int kMaxSafeLineBytes = 240;
bool BodyHasLongLine(const char *body) {
if (body == nullptr)
return false;
int lineLen = 0;
for (; *body != '\0'; ++body) {
if (*body == '\n') {
lineLen = 0;
} else if (++lineLen > kMaxSafeLineBytes) {
return true;
}
}
return false;
}
// Walks each line once. At every byte position, checks every
// registered prefix; on first match, invokes the extractor. First
// extractor that returns a positive spellID wins for the whole macro
// — matches the engine parser's "first hit, line-at-a-time" walk.
int ScanBody(const char *body) {
if (body == nullptr)
return 0;
const char *line = body;
while (*line != '\0') {
const char *eol = line;
while (*eol != '\0' && *eol != '\n')
++eol;
for (const char *p = line; p < eol; ++p) {
for (auto *node = g_patternHead; node != nullptr; node = node->next) {
if (p + node->prefixLen > eol)
continue;
if (std::memcmp(p, node->prefix, node->prefixLen) != 0)
continue;
const int spellID = node->extract(p + node->prefixLen, eol);
if (spellID > 0)
return spellID;
// Prefix matched but extraction failed (malformed
// args). Don't keep checking other prefixes at this
// position — they couldn't possibly also match here.
break;
}
}
line = (*eol == '\n') ? eol + 1 : eol;
}
return 0;
}
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)) {
*cacheField = 0;
const int spellID = ScanBody(body);
if (spellID > 0)
*cacheField = spellID;
return;
}
MacroParse_o(macroEntry);
if (*cacheField != 0)
return;
const int spellID = ScanBody(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)};
} // namespace
PatternAutoRegister::PatternAutoRegister(const char *p, ArgExtractor e)
: prefix(p),
prefixLen(static_cast<unsigned>(std::strlen(p))),
extract(e),
next(g_patternHead) {
g_patternHead = this;
}
} // namespace Spell::MacroPrimarySpell
+44
View File
@@ -0,0 +1,44 @@
// 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::MacroPrimarySpell {
// Parses the argument(s) immediately after a known function-call
// prefix. `p` points to the first byte past the closing `(` of the
// prefix (so for `CastSpellNoToggle("Shoot")`, `p` would be at the
// `"`). `end` is end-of-line. Returns the resolved spellID, or 0 if
// extraction failed (malformed args, unknown spell, etc.).
using ArgExtractor = int(*)(const char *p, const char *end);
// File-scope static auto-registers a `(prefix, extractor)` pair. Each
// feature module that wants its Lua wrapper to tag macros declares:
//
// static const Spell::MacroPrimarySpell::PatternAutoRegister _r{
// "CastSpellNoToggle(", &MyExtractor};
//
// The macro-parser hook walks every line once, checks each registered
// prefix at each position, and on match invokes the extractor to
// resolve a spellID. The resolved ID is written to the macro's
// primary-spell cache field — drives auto-repeat highlight, cooldown
// overlay, etc. in action-bar UIs (pfUI).
struct PatternAutoRegister {
PatternAutoRegister(const char *prefix, ArgExtractor extract);
const char *prefix;
unsigned prefixLen;
ArgExtractor extract;
PatternAutoRegister *next;
};
} // namespace Spell::MacroPrimarySpell
+90
View File
@@ -0,0 +1,90 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellMechanicByID(spellID)` -> (mechanicID, localizedName)
//
// Reads the spell-level Mechanic field straight out of Spell.dbc, so it
// covers *every* spell the client knows — not just the player's
// spellbook — with no caching or network round-trip (Spell.dbc is fully
// resident from boot). This replaces hand-maintained spellID -> mechanic
// tables (e.g. SuperCleveRoidMacros' `CCSpellMechanics`) that addons keep
// solely because vanilla exposes no Lua reader for the field.
//
// Returns:
// - nothing (nil) for an invalid / out-of-range spell ID
// - (0, nil) for a known spell that carries no mechanic
// - (mechanicID, name) otherwise. `name` is the enUS SpellMechanic.dbc
// name (always English, locale-independent — it's a stable token
// consumers match against). nil only if the mechanic has no record /
// no English name; the numeric ID is still returned in that case.
//
// The mechanic numbering is WoW's standard SpellMechanic enum
// (1 = Charm, 5 = Fear, 7 = Root, 12 = Stun, 17 = Polymorph, ...);
// addons map those IDs to their own names as needed.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Spell::Mechanic {
// SpellMechanic.dbc name — always the enUS (index 0) column, regardless
// of client locale. The mechanic name is a stable token consumers key
// off (`"rooted"`, `"stunned"`, …), so it must be identical on every
// client; a localized value would break cross-locale string matching.
// Vanilla only populates the enUS column of this table anyway. Returns
// nullptr if the record is missing or the English slot is empty.
static const char *MechanicName(uint32_t mechanicID) {
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLMECHANIC_RECORDS,
Offsets::VAR_SPELLMECHANIC_COUNT, mechanicID);
if (rec == nullptr)
return nullptr;
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLMECHANIC_NAME);
const char *s = names[0]; // enUS
return (s != nullptr && s[0] != '\0') ? s : nullptr;
}
static int __fastcall Script_GetSpellMechanicByID(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil for invalid / out-of-range spell IDs
const uint32_t mechanicID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MECHANIC);
Game::Lua::PushNumber(L, static_cast<double>(mechanicID));
if (mechanicID == 0) {
Game::Lua::PushNil(L); // known spell, no mechanic
return 2;
}
// Locale name with enUS fallback (nullptr → nil if even English is
// empty); the caller still gets the numeric ID either way.
Game::Lua::PushString(L, MechanicName(mechanicID)); // pushstring(NULL) → pushnil
return 2;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellMechanicByID",
&Script_GetSpellMechanicByID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Mechanic
+88
View File
@@ -0,0 +1,88 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "spell/Mod.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
namespace Spell::Mod {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's class SpellFamilyName, or 0 if it can't be resolved
// (e.g. at the login screen). Gates the SpellMod family match — only
// spells of the player's own class family receive the player's mods, so
// a same-flag-bit spell of another family isn't falsely modified.
uint32_t PlayerSpellFamily() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve("player"));
if (unit == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
const uint8_t *cls = DBC::Record(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT, classByte);
if (cls == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(cls + Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
}
} // namespace
float Apply(const uint8_t *spellRecord, int op, float base) {
if (spellRecord == nullptr)
return base;
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if (familyName == 0 || (attrEx2 & Offsets::SPELL_ATTR_EX2_NO_SPELL_MODS) != 0)
return base;
if (familyName != PlayerSpellFamily())
return base;
const uint64_t familyFlags = *reinterpret_cast<const uint64_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if (familyFlags == 0)
return base;
const auto *flatTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_FLAT_TABLE);
const auto *pctTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_PCT_TABLE);
const int opByteOffset = op * 4;
int flat = 0;
int pct = 0;
for (int slot = 0; slot < Offsets::SPELLMOD_SLOT_COUNT; ++slot) {
if (((familyFlags >> slot) & 1ull) == 0)
continue;
const int byteOff = slot * Offsets::SPELLMOD_SLOT_STRIDE + opByteOffset;
flat += *reinterpret_cast<const int *>(flatTable + byteOff);
pct += *reinterpret_cast<const int *>(pctTable + byteOff);
}
if (flat == 0 && pct == 0)
return base;
int pctTotal = pct + 100;
if (pctTotal < 0)
pctTotal = 0;
return (base + static_cast<float>(flat)) * static_cast<float>(pctTotal) * 0.01f;
}
} // namespace Spell::Mod
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// `Spell::Mod` — the engine's SpellMod (talent / item) system, for the
// LOCAL PLAYER. Replicates `FUN_006e6b30` / `FUN_006e6bf0`: the player's
// flat and percent modifiers are accumulated into two tables indexed
// `[familyFlagBit][op]` (see `VAR_SPELLMOD_*` in Offsets.h). A spell's
// value for a given op is modified by summing, over the bits set in the
// spell's SpellFamilyFlags, the flat and percent entries, then applying
// `value = (value + flat) * (100 + pct) * 0.01`.
//
// This underlies many spell properties — radius, cast time, mana cost,
// duration, range, cooldown — each a distinct `op` (e.g.
// `Offsets::SPELLMOD_OP_RADIUS`). The op numbering is the engine's
// internal one; verify each against the binary before relying on it.
//
// Inherently local-player-only: the client tracks spell mods for the
// player alone, so there's no way to know another caster's talents.
namespace Spell::Mod {
// Returns `base` with the local player's modifiers for `op` applied to
// `spellRecord` (a `Spell.dbc` record pointer). Returns `base` unchanged
// when no modifier matches — wrong spell family, mods disabled on the
// spell, the player can't be resolved, or no relevant talent/item.
float Apply(const uint8_t *spellRecord, int op, float base);
} // namespace Spell::Mod
+98
View File
@@ -0,0 +1,98 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellPowerCost(spellIdentifier)` -> SpellPowerCostInfo[]
//
// Returns an array of power-cost tables, or nil if the spell isn't found
// or has no resource cost. Vanilla spells have exactly one power cost, so
// the array always holds at most one entry.
//
// `cost` is the **effective** cost for the local player — base + level
// scaling + ManaCostPercent-of-resource + the descriptor power-cost
// modifiers + the cost SpellMod (op 14) — via the engine's own cost
// helper `FUN_006e31b0` (the same value the engine charges). So talents
// like reduced-cost spells are reflected. Per-entry fields:
//
// type Enum.PowerType (0 mana, 1 rage, 2 focus, 3 energy,
// 4 happiness) — the spell's PowerType (+0x7C)
// name power token ("MANA", "RAGE", …)
// cost effective cost (see above)
// minCost == cost; vanilla has no "optional" cost component
// costPercent ManaCostPercent (+0x270) — the spell's %-of-base-
// resource cost, or 0 for a flat cost
// costPerSec 0 — vanilla doesn't expose a per-second channel cost
// in these terms
// requiredAuraID 0 — vanilla has no form/aura-conditional costs here
// hasRequiredAura false
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "unit/Power.h"
#include <cstdint>
namespace Spell::PowerCost {
namespace {
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
constexpr int OFF_POWER_TYPE = 0x7C; // int PowerType
constexpr int OFF_MANA_COST_PERCENT = 0x270; // int, % of base resource (0 = flat)
uint32_t EffectiveCost(int spellID) {
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
}
int __fastcall Script_GetSpellPowerCost(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return 0; // nil — spell not found
const uint32_t cost = EffectiveCost(spellID);
if (cost == 0xFFFFFFFF)
return 0; // engine error (no player context, etc.)
const int powerType = *reinterpret_cast<const int *>(rec + OFF_POWER_TYPE);
const int costPercent = *reinterpret_cast<const int *>(rec + OFF_MANA_COST_PERCENT);
if (cost == 0 && costPercent == 0)
return 0; // nil — no resource cost
// SpellPowerCostInfo[] — a one-element array: outer[1] = entry.
Game::Lua::NewTable(L); // outer
Game::Lua::PushNumber(L, 1.0); // key 1
Game::Lua::NewTable(L); // entry (top)
Game::Lua::SetFieldNumber(L, "type", static_cast<double>(powerType));
Game::Lua::SetFieldString(L, "name", Unit::Power::PowerTypeToken(powerType));
Game::Lua::SetFieldNumber(L, "cost", static_cast<double>(cost));
Game::Lua::SetFieldNumber(L, "minCost", static_cast<double>(cost));
Game::Lua::SetFieldNumber(L, "costPercent", static_cast<double>(costPercent));
Game::Lua::SetFieldNumber(L, "costPerSec", 0);
Game::Lua::SetFieldNumber(L, "requiredAuraID", 0);
Game::Lua::SetFieldBool(L, "hasRequiredAura", false);
Game::Lua::SetTable(L, -3); // outer[1] = entry
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellPowerCost", &Script_GetSpellPowerCost);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::PowerCost
+120
View File
@@ -0,0 +1,120 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.GetSpellRadius(spellID)` and the parallel
// `GetSpellRadius(slot, bookType)` -> (baseRadius, modifiedRadius)
//
// Returns the spell's AOE radius in yards, or nil for a non-AOE spell
// (no effect carries a radius). Two values:
// - baseRadius — the raw SpellRadius.dbc value (caster-independent,
// what an AOE tracker watching other units wants)
// - modifiedRadius — baseRadius with the LOCAL PLAYER's spell modifiers
// (talents / items, e.g. a Mage's Arctic Reach)
// applied. Equal to baseRadius when the player has
// no matching modifier.
//
// Two signatures mirroring the other dual-signature spell globals
// (`GetSpellInfo`, `SpellHasRange`, etc.): the namespaced form takes a
// modern spell *identifier* (numeric ID or name), the bare global takes
// the vanilla `(slot, bookType)` spellbook position. `bookType` is the
// `GetSpellName` convention: `"pet"` → pet book, anything else (or
// omitted) → player book.
//
// baseRadius reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]` and
// resolves each into `SpellRadius.dbc`, taking the largest base radius
// across the three effects. No caster-level scaling (the engine's
// FUN_006e6350 also adds `radiusPerLevel * casterLevel`, which needs a
// unit context; modern `GetSpellRadius` reports the base value).
//
// modifiedRadius applies the local player's SpellMods for op 6 (radius)
// via `Spell::Mod::Apply`. It is inherently local-player-only — the
// client tracks spell mods for the player alone, so there's no way to
// know another caster's talents.
//
// All DBCs / mod tables are resident from boot, so the lookup is
// synchronous with no caching.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
#include "spell/Mod.h"
#include <cstdint>
#include <cstring>
namespace Spell::Radius {
// Largest base radius across the spell's effects, or a negative sentinel
// (< 0) if no effect has a radius.
static float MaxBaseRadius(const uint8_t *record) {
float best = -1.0f;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int radiusIndex = *reinterpret_cast<const int *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
const uint8_t *rad = DBC::Record(Offsets::VAR_SPELL_RADIUS_RECORDS,
Offsets::VAR_SPELL_RADIUS_COUNT,
static_cast<uint32_t>(radiusIndex));
if (rad == nullptr)
continue;
const float r = *reinterpret_cast<const float *>(rad + Offsets::OFF_SPELL_RADIUS_VALUE);
if (r > best)
best = r;
}
return best;
}
// Pushes (baseRadius, modifiedRadius) for `spellID`, or nil (0 returns)
// for an invalid / non-AOE spell.
static int PushRadiusForSpellID(void *L, int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0; // nil
const float base = MaxBaseRadius(record);
if (base < 0.0f)
return 0; // nil — not an AOE spell
Game::Lua::PushNumber(L, static_cast<double>(base));
Game::Lua::PushNumber(L,
static_cast<double>(Spell::Mod::Apply(record, Offsets::SPELLMOD_OP_RADIUS, base)));
return 2;
}
// `C_Spell.GetSpellRadius(spellID)` — modern spell-identifier form.
static int __fastcall Script_C_Spell_GetSpellRadius(void *L) {
return PushRadiusForSpellID(L, Spell::Arg::ResolveSpellID(L, 1));
}
// `GetSpellRadius(slot, bookType)` — vanilla-style positional shape.
static int __fastcall Script_GetSpellRadius(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
int bookType = 0;
if (Game::Lua::IsString(L, 2)) {
const char *s = Game::Lua::ToString(L, 2);
if (s != nullptr && _stricmp(s, "pet") == 0)
bookType = 1;
}
return PushRadiusForSpellID(L, Spell::Lookup::SpellbookSlotToID(slot, bookType));
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRadius",
&Script_C_Spell_GetSpellRadius);
Game::Lua::RegisterGlobalFunction("GetSpellRadius", &Script_GetSpellRadius);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Radius
+17 -8
View File
@@ -25,11 +25,12 @@ namespace Spell::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
// Spell.dbc record field offsets we read for usability. Fully documented
// in CLAUDE.md.
constexpr int OFF_SPELL_POWER_TYPE = 0x7C; // int8: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost (fallback only)
// Returns true if the player knows `spellID` per the engine's spell-
// knowledge bitmap at `[VAR_PLAYER_SPELL_BITMAP]`. Same check
@@ -135,8 +136,9 @@ bool HasReagents(void *L, const uint8_t *record) {
// 3. Player descriptor is reachable (post-login).
// 4. Player is alive (HEALTH > 0).
// 5. Spell is not on cooldown (engine cooldown helper).
// 6. Player has enough power for `ManaCost` of the spell's
// `PowerType`. Only this failure flips `noMana=true`.
// 6. Player has enough power for the spell's *effective* cost (talent
// reductions applied) of its `PowerType`. Only this flips
// `noMana=true`.
// 7. Player has all required reagents in bags (Reagent[8] /
// ReagentCount[8] from the spell record).
//
@@ -170,16 +172,23 @@ Usability ComputeUsability(void *L, int spellID) {
if (IsOnCooldown(spellID))
return r;
// Mana check is the only one that flips noMana.
// Mana check is the only one that flips noMana. Use the engine's
// effective-cost helper (op-14 SpellMod + descriptor power-cost mods +
// ManaCostPercent), so talent reductions like Frost Channeling count —
// not just the base ManaCost. Falls back to base if the engine can't
// resolve a cost (shouldn't happen here: the player descriptor
// resolved above, so it has a player context).
const int powerType = static_cast<int>(*reinterpret_cast<const int8_t *>(
record + OFF_SPELL_POWER_TYPE));
if (powerType >= 0 && powerType <= 4) {
const int manaCost = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + OFF_SPELL_MANA_COST));
if (manaCost > 0) {
auto getCost = reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST);
uint32_t cost = getCost(spellID, 0 /* local player */);
if (cost == 0xFFFFFFFF)
cost = *reinterpret_cast<const uint32_t *>(record + OFF_SPELL_MANA_COST);
if (cost > 0) {
const int currentPower = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + powerType * 4);
if (currentPower < manaCost) {
if (currentPower < static_cast<int>(cost)) {
r.noMana = true;
return r;
}
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
// Shared world-position read for any CGObject-derived unit. The
// position comes from the object's `GetPosition` virtual (vtable
// slot 5) — the same one `CheckInteractDistance` / `UnitInRange` use.
// Header-only so callers (UnitInRange, CastAtUnit, UseAtUnit) don't
// each re-derive the vtable-slot math.
namespace Unit::Position {
// Resolves a unit token ("player", "target", "party1", "mouseover",
// …) to a `CGUnit *`. Returns nullptr for a recognized-but-absent
// token (e.g. "party5" with no such member). NOTE: the engine's
// resolver raises a Lua error for genuinely unrecognized token
// strings — same contract as `UnitHealth("garbage")` — so only feed
// it real unit tokens, or strings you're willing to let error.
inline void *ResolveToken(const char *token) {
if (token == nullptr)
return nullptr;
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
// Reads `obj`'s world position into `out[3]` (x, y, z). Calls
// `obj->vtable[5](outBuf)`; the returned float* may equal `outBuf`
// (object filled it) or point at a cached position field — either
// way the values land in `out`. Returns false if `obj` is null or
// the virtual yields no position (object has no known position yet).
inline bool Read(void *obj, float out[3]) {
if (obj == nullptr)
return false;
auto **vtable = *reinterpret_cast<void ***>(obj);
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
auto fn = reinterpret_cast<GetPosition_t>(
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
float *p = fn(obj, out);
if (p == nullptr)
return false;
if (p != out) {
out[0] = p[0];
out[1] = p[1];
out[2] = p[2];
}
return true;
}
// Convenience: resolve a token and read its position in one step.
inline bool ReadToken(const char *token, float out[3]) {
return Read(ResolveToken(token), out);
}
} // namespace Unit::Position
+18 -16
View File
@@ -33,11 +33,29 @@
#include "Game.h"
#include "Offsets.h"
#include "unit/Power.h"
#include <cstdint>
namespace Unit::Power {
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
// can't appear on a 1.12 unit but we map them anyway in case a
// private server pushes one through.
const char *PowerTypeToken(int type) {
switch (type) {
case 0: return "MANA";
case 1: return "RAGE";
case 2: return "FOCUS";
case 3: return "ENERGY";
case 4: return "HAPPINESS";
case 5: return "RUNES";
case 6: return "RUNIC_POWER";
}
return "UNKNOWN";
}
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
@@ -68,22 +86,6 @@ uint32_t PowerDivisor(int type) {
return table[type];
}
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
// can't appear on a 1.12 unit but we map them anyway in case a
// private server pushes one through.
const char *PowerTypeToken(int type) {
switch (type) {
case 0: return "MANA";
case 1: return "RAGE";
case 2: return "FOCUS";
case 3: return "ENERGY";
case 4: return "HAPPINESS";
case 5: return "RUNES";
case 6: return "RUNIC_POWER";
}
return "UNKNOWN";
}
// `Enum.PowerType` — modern Blizzard-style enum table. Slot 4 is
// `Happiness` in 1.12 (pet happiness 0..3); modern retail relabels
+23
View File
@@ -0,0 +1,23 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Unit::Power {
// Modern power-type token for a PowerType value: 0 MANA, 1 RAGE, 2 FOCUS,
// 3 ENERGY, 4 HAPPINESS, 5 RUNES, 6 RUNIC_POWER, anything else "UNKNOWN".
// Used by `UnitPowerType`'s second return and by spell power-cost APIs.
const char *PowerTypeToken(int type);
} // namespace Unit::Power
+5 -39
View File
@@ -15,42 +15,17 @@
// uses, applied with a fixed 40-yard threshold.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
#include <cstring>
namespace Unit::Range {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
void *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
// Calls `obj->vtable[5](outBuf)`. The returned float* may equal
// outBuf (the object filled it directly) or point at a cached
// position field on the object — either way, deref it for x/y/z.
// Returns nullptr if the object's vtable virtual returns null (no
// known position for this object yet).
float *ReadPosition(void *obj, float outBuf[3]) {
if (obj == nullptr)
return nullptr;
auto **vtable = *reinterpret_cast<void ***>(obj);
auto fn = reinterpret_cast<GetPosition_t>(
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
return fn(obj, outBuf);
}
int __fastcall Script_UnitInRange(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
@@ -69,19 +44,10 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
void *unit = ResolveUnit(token);
void *player = ResolveUnit("player");
if (unit == nullptr || player == nullptr) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
float unitBuf[3] = {};
float playerBuf[3] = {};
const float *unitPos = ReadPosition(unit, unitBuf);
const float *playerPos = ReadPosition(player, playerBuf);
if (unitPos == nullptr || playerPos == nullptr) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
+85
View File
@@ -0,0 +1,85 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
// italic text under their name in the tooltip): "Innkeeper",
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
// only one value, so addons that want the subtitle have to roll their
// own — until now.
//
// Returns `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Player units — they don't have a row in the creature cache.
// - NPCs whose creature data hasn't synced yet (rare; happens when
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
// hasn't arrived).
// - NPCs with no subname (most non-vendor / non-trainer creatures).
//
// Raises a Lua error on missing / non-string `unit` argument — same
// semantics as the engine's `UnitName`.
//
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
// `[+0x10]` (subname C string). Verified path; same one
// VanillaMinimapTracking's blip subname filter uses.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SubName {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitSubName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr)
return 0;
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
if (cacheRow == nullptr)
return 0;
auto *subName = *reinterpret_cast<const char *const *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
if (subName == nullptr || *subName == '\0')
return 0;
Game::Lua::PushString(L, subName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SubName