Compare commits

...
38 Commits
Author SHA1 Message Date
Brues 2bd33288c9 event: raise custom-event reservation cap (32 -> 64)
AutoReserve silently drops any reservation past MAX_RESERVED, and the
codebase crossed 32 file-scope reservations, so a shifting subset of custom
events never claimed a table slot -> Lookup returned -1 and Fire no-op'd.
Adding the three AUCTION_MULTISELL_* events moved the overflow onto
ITEM_DATA_LOAD_RESULT / GET_ITEM_INFO_RECEIVED, which stopped firing.

Raise the cap to 64 (well above the ~45 total incl. the lazy TTS events) and
add a comment warning to keep it above the AutoReserve count. Verified via
_classicapi_DumpAllEvents: all custom events now claim slots, item events
included.
2026-07-19 18:29:20 -05:00
Brues bf7e476c10 quest: add C_QuestLog.GetHeaderIndexForQuest
Returns the 1-based log index of the collapsible header (zone / category)
a questID sits under, or nil if the quest isn't in the log. The log is laid
out header-then-its-quests, so it locates the quest's entry and walks back to
the nearest preceding header row (+8 non-NULL). Pairs with GetQuestLogTitle
to fetch the category name.

Verified in-game: quest 60142 -> header index 1 -> GetQuestLogTitle "Dun
Morogh" with isHeader true.
2026-07-19 17:34:52 -05:00
Brues 8f3ed96d51 quest: add C_QuestLog.GetLogIndexForQuestID
The inverse of GetQuestIDForLogIndex: returns the 1-based quest-log index of
a questID, or nil if the quest isn't in the log. Walks VAR_QUEST_LOG_ENTRIES,
skips header rows via the +8 header-pointer gate, and returns the first
matching entry's index. The index spans the full entry list (headers
included), so it feeds straight into GetQuestLogTitle / the other index-based
quest-log accessors.

Verified in-game: index->ID->index and ID->index->ID both round-trip.
2026-07-19 17:30:46 -05:00
Brues e1ffaab6b4 item: add native C_Item.GetItemInfo (full modern tuple)
Implement C_Item.GetItemInfo(itemInfo) in C, returning the full modern
tuple instead of the Lua alias to the vanilla global (which only returned
1.12's short set). Sourced entirely from the cached ItemStats_C record plus
the class/subclass/inventory-type DBC name lookups item/Info.cpp already
does for GetItemInfoInstant — no new duplication.

Returns 18 values: itemName, itemLink, itemQuality, itemLevel, itemMinLevel,
itemType, itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
classID, subclassID, bindType, expansionID (0), setID (nil if none),
isCraftingReagent (false; no such flag in 1.12), itemDescription. itemTexture
is the icon path (no fileID system in 1.12). On a cache miss it warms the
cache and returns nil, landing on the GET_ITEM_INFO_RECEIVED retry — same
async contract as the stock global.

Drops the `C_Item.GetItemInfo = GetItemInfo` shim from ItemUtil.lua so the
native implementation isn't clobbered at addon load.

Verified in-game against Rancher's Trousers (nil setID) and Blackened Defias
Boots (setID 161) — all 18 fields correct.
2026-07-19 16:52:53 -05:00
Brues c13b1c8eb7 item: fix C_NewItems flagging unequipped / bank-pulled items as new
The new-item diff only tracked bag contents, so an item moving INTO a bag
from anywhere out of scope looked identical to a fresh loot. Unequipping a
piece of gear (equipment -> bag) flagged it new; same for pulling an item
out of the bank.

Widen the "already owned" baseline to the player's whole inventory — bags,
worn equipment (paperdoll + equipped bags), and the bank (main + bank bags)
— while still only flagging bag items. An item whose GUID was already owned
somewhere is a move, not an acquisition, so it isn't flagged. Bank GUIDs are
read straight from the invMgr array (the gate-bypass C_Item.GetItemCount
already uses; the array is server-populated at login), so no bank window is
needed. Cap raised 256 -> 512 to hold bags + equipment + a full bank.
2026-07-19 01:43:09 -05:00
Brues bedc5d6740 Fix OnEvent handler example to use vanilla globals 2026-07-18 15:44:40 -05:00
Brues bdef428ff7 Update README.md 2026-07-18 15:42:32 -05:00
Brues 25b1a1a95c auctionhouse: add C_AuctionHouse.PostItem (multi-stack auto-split)
Post numStacks auctions of `quantity` each from one source stack in a
single call (retail multi-sell style). Vanilla's CMSG_AUCTION_SELL_ITEM
(opcode 0x256, verified from Script_StartAuction 0x004CE770) has no count
field like WotLK's, so the server posts the whole item object a GUID points
at; posting a partial quantity requires the client to split it into a real
bag item first. Both the split and the post are server-authoritative and
async, so this runs a WorldTick state machine:

  Idle -> (source > quantity) split `quantity` into a reused free general
          bag slot via Item::Swap::MoveCount (CMSG_SPLIT_ITEM)
       -> SplitWait until the stack materializes
       -> point the sell-slot globals at it and call the engine's own
          Script_StartAuction (reusing all its per-stack validation)
       -> PostWait until the server pulls it from the slot -> next stack.
  The final exact stack posts the source object directly, no split.

At most one reused free slot is needed (each post frees it for the next
split); a whole-stack post needs none. Per-step ~10s timeout aborts
gracefully. Progress via retail event names AUCTION_MULTISELL_START /
_UPDATE(done,total) / _FAILURE. Immediate rejections return (false, reason).

New offsets: FUN_SCRIPT_START_AUCTION, VAR_AUCTION_AUCTIONEER_GUID_LO/HI,
VAR_AUCTION_DURATION_TABLE ({120,480,1440} minutes = 2h/8h/24h).

v1 limits: single source stack (no cross-bag aggregation), general-purpose
bags only for the temp split, one job at a time.
2026-07-18 14:05:31 -05:00
Brues 5e23332880 lua: add math.fmod
Backport math.fmod to 1.12's Lua 5.0. Pure 5.0->5.1 rename: 5.0 ships the
identical C function as math.mod (mathlib entry, FUN 0x007FAFD0 —
checknumber(1/2) -> fmod -> pushnumber); 5.1 renamed the Lua-facing entry
to math.fmod. Registered as a direct alias of that function pointer on both
the in-game and glue states (BaseLib::MathLib), no wrapper. math.mod stays
available, so code written for either name works.
2026-07-18 05:05:39 -05:00
Brues eb36b3e09e lua: add string.match / string.gmatch
Backport the Lua 5.1 pattern helpers to 1.12's Lua 5.0 (BaseLib::StringLib),
both reusing pattern machinery 5.0 already ships:

- string.match(s, pattern [, init]) calls the engine's str_find
  (0x007FC3B0) and reshapes the result — captures, or the whole match when
  the pattern has none, or nil. match and find are the same operation in
  lstrlib.c; find returns start/end, match returns the captures.
- string.gmatch is 5.0's string.gfind verbatim (renamed in 5.1), registered
  as a direct alias of the engine gfind (0x007FCFA0) — no wrapper.

Registered on both the in-game and glue Lua states. Only the
string.foo(s, ...) form works, not ("x"):foo(...): WoW's VM strips
type-metatables for non-table/userdata values (both lua_setmetatable and
luaT_gettmbyobj hard-return no-metamethod for strings), so string method
sugar can't be enabled client-side without hooking a hot VM core function.
Documented as a known limitation.

Drops the Util/String.lua polyfill (was just string.gmatch = string.gfind),
now redundant, and its .toc entry.
2026-07-18 04:47:45 -05:00
Brues 16452519a6 lua: add select(index, ...); consolidate Lua backports under one section
Backport Lua 5.1's select() to 1.12's Lua 5.0 (BaseLib::Select). Two
forms: select('#', ...) returns the vararg count; select(n, ...) returns
the args from position n on (negative n counts from the end). Ported from
luaB_select (lbaselib.c) — the numeric form pushes nothing and just
returns top-n, handing back the slice already on the stack. Registered on
both the in-game and glue Lua states (both are 5.0 and equally missing it).

Docs: gather the scattered Lua base-library restorations (select,
table.wipe, coroutine.*) into a single "Lua" section in API.md, and
collapse the separate Coroutine/Table rows in the README table into one
Lua row. No prose lost — the coroutine and table.wipe entries moved
verbatim under the new section (coroutine.* as level-4 subsections).
2026-07-18 04:21:40 -05:00
Brues 33c5f3041d container: add C_Container.GetContainerItemInfo
Backport the modern structured-table accessor. Vanilla's global
GetContainerItemInfo (Script_GetContainerItemInfo, 0x004F9670) returns
only a flat texture/itemCount/locked/quality/readable tuple; assemble the
full ContainerItemInfo table from the same descriptor + item-cache sources
plus the fields 1.12 never surfaced (hyperlink, hasNoValue, itemID,
isBound, itemName).

Live per-instance fields (stackCount, isLocked, isBound, itemID, hyperlink)
are always present; cache-derived fields (iconFileID, quality, hasLoot,
hasNoValue, itemName) are omitted/nil until the item's static data lands.
Reuses Item::StatAccum::FetchRecord and Item::Link for the cache peek and
link/name build (no new duplication); gathers all data into locals before
building the table so the engine link/name builders never interleave with
the half-built table on the Lua stack.

readable mirrors the engine branch at 0x004F97BD (static page text OR the
per-instance readable-text id at descriptor +0x9C, added as
OFF_DESCRIPTOR_READABLE_TEXT_ID). hasLoot is a best-effort static LOOTABLE
approximation (vanilla can't track post-loot emptiness client-side).
2026-07-18 04:06:11 -05:00
Brues e8a03d584b spell: add GetSpellBonusDamage / GetSpellBonusHealing
GetSpellBonusDamage(school) reads the player's per-school spell damage
directly from the CGPlayer sub-struct at [player+0xE68]+0xFD4 (POS) / +0xFF0
(NEG), net = POS-NEG. That field isn't in the broadcast descriptor (+0x110
reads zero — the vanilla server never sends it there), but the client keeps a
fully-computed copy at +0xE68 with gear/enchants/buffs/talents/sets baked in.
Same source nampower's GetSpellPower reads; verified 613 generic / 647 Fire on
a geared mage. 1-based school, matching 3.3.5's Script_GetSpellBonusDamage.

GetSpellBonusHealing() has no such field (vanilla lacks a healing-done field
entirely), so it's derived to mirror the server's SpellBaseHealingBonusDone:
  - flat healing: SPELL_AURA_MOD_HEALING_DONE (135) off gear equip-effects,
    item enchants, random suffixes, and active buffs
  - Spirit conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT (175) x
    total Spirit / 100  (Priest Spiritual Guidance)
  - Armor conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_ARMOR_PERCENT (199) x
    armor / 100  (Turtle Ironclad)
Percents are read straight from Spell.dbc, so it's generic across any
aura-175/199 talent/buff including Turtle customs, and gated on SPELL_ATTR_
PASSIVE for the known-spell-bitmap walk. Spirit/Armor come from UNIT_FIELD_
STAT4 (+0x250) / UNIT_FIELD_RESISTANCES[0] (+0x254).

Item flat healing routes through the shared Item::StatAccum engine rather than
re-implementing the item spell/suffix/enchant walk: extract a reusable
ApplyEnchant() (from ApplyRandomSuffix) plus a Value() reader, and read
ITEM_MOD_SPELL_HEALING_DONE_SHORT.

The healing sum is expensive (StatAccum over 19 items + buff table + the 27k
known-spell bitmap), so it's cached and recomputed only when a player stat
input changes. New Player::StatSignal (a pull-based epoch) is bumped from the
aura hooks (player-guarded), the equipment observer, and the spell learn/
unlearn writers (0x4B25B0 / 0x4B2C50) -- so buffs, gear, and talent respecs
all invalidate instantly. A coarse world-tick recompute remains only as a
backstop against a missed signal.
2026-07-18 03:51:20 -05:00
Brues adba73a6d8 build: stamp PE version resource on the DLL
Wire a Windows VERSIONINFO resource to the existing CLASSICAPI_TAG so the
DLL's file/product version (Explorer -> Properties -> Details, and
GetFileVersionInfo) matches the CLASSIC_API_VERSION Lua global. Both derive
from the vMAJOR.MINOR.PATCH tag; an untagged dev build gets the 9999.99.99
"newer than everything" sentinel.

Enables the RC language, keeps the tag's major/minor/patch components that
were previously discarded after the integer math, and configure_file's a
version.rc into the build. The resource lands in .rsrc, untouched by the
.rdata/.pdata section merges.
2026-07-17 15:39:04 -05:00
Brues d09137cae0 player: add C_PlayerInfo Get{Name,Class,Race,Sex} / IsConnected(playerLocation)
The PlayerLocation-taking C_PlayerInfo accessors. Resolve the location to a
unit token - unit locations use their token, GUID locations via
Unit::Identity::TokenFromGUID (while the GUID has a live token) - then:

- GetName / GetSex / IsConnected tail-call the engine's Script_UnitName /
  Script_UnitSex / Script_UnitIsConnected (the GetFactionInfoByID
  forward-the-return pattern), reusing all vanilla unit logic. GetSex must go
  through the engine because the raw UNIT_FIELD_BYTES_0 gender byte is 0/1
  while the API reports 2/3. IsConnected defaults to the local player when
  called with no location.
- GetRace / GetClass read UNIT_FIELD_BYTES_0 for the numeric ids vanilla's
  UnitRace / UnitClass don't return (login-time mirror globals for "player",
  else the descriptor bytes); GetClass resolves className / classFile from
  ChrClasses.dbc like C_CreatureInfo.GetClassInfo. classID/raceID round-trip
  with C_CreatureInfo.

Only the unit and guid location kinds resolve on 1.12; the chat/who/BG/
community/voice kinds return nil. New file - player/Info.cpp is the separate
NameCache/GetPlayerInfoByGUID module. Adds the three Script_Unit* offsets
from raw_globals.txt.
2026-07-17 01:20:01 -05:00
Brues 5721fb65a7 addon: add PlayerLocation / PlayerLocationMixin
Backport the modern PlayerLocation parameter object (Blizzard_ObjectAPI) - a
lightweight descriptor wrapping one way to identify a player (GUID, unit
token, chat line, /who index, battlefield-score index, ...). Pure table
bookkeeping like the existing ItemLocation mixin, so ported addon code can
construct and inspect it unchanged.

The GUID / unit / chatLine / whoIndex / battlefieldScore kinds map to real
1.12 concepts; community / voice / Battle.net are kept as harmless data
holders so the mixin matches retail exactly. The only two methods that reach
into systems 1.12 lacks - IsValid (Club) and IsBattleNetGUID (account info) -
are guarded so they degrade gracefully instead of erroring on the missing
namespaces.
2026-07-17 01:08:43 -05:00
Brues 3afd403eae container: add C_Container.HasContainerItem
Classic Era addition (present in the 1.15.x client, absent in 5.4.8) — a
plain occupancy check for a bag slot. Occupancy sibling of
GetContainerItemID: resolves (bagIndex, slotIndex) the same way and returns
whether the CGItem is non-null. Presence only, so it reports true for a
just-looted item even before its static data caches. Returns false for
empty slots and out-of-range indices.
2026-07-17 00:37:23 -05:00
Brues e1030213d3 container: add C_Container.CalculateTotalNumberOfFreeBagSlots
Sum of free slots across the player's general-purpose bags (backpack +
equipped bags 0..4 with BagFamily 0), matching FrameXML's implementation.
Reuses the module's existing ResolveBagInfo (slot count + family) and
CountFreeSlotsInBag, so it's just their sum with the family == 0 filter.

Same sparse-BagFamily caveat as GetContainerNumFreeSlots: vanilla server
data leaves most bags' family field at 0, so specialty bags retail would
exclude get counted here - consistent with the per-bag values it sums.
2026-07-16 23:30:02 -05:00
Brues c1cf437bf8 item: route all local player resolvers through Unit::Identity
Nine modules each rolled their own ResolvePlayer / PlayerInvMgr helper on the
throwing FUN_RESOLVE_UNIT_TOKEN("player"). Delete them all and route through
the canonical non-throwing Unit::Identity::PlayerObject(), plus a new
Unit::Identity::PlayerInventoryManager() (PlayerObject + inventory-manager
offset, mirroring the existing PlayerDescriptor accessor).

Verified equivalent by disassembly: FUN_RESOLVE_UNIT_TOKEN is TokenToGUID ->
FUN_OBJECT_RESOLVE_BY_GUID, the same object-manager lookup PlayerObject()
performs, differing only in a type-mask the player object passes either way.
Callers therefore get the identical CGPlayer (and inventory manager) but
non-throwing - null pre-world instead of a raised Lua error. That also makes
item/Location's equipment resolver safe to call outside a Lua callback (e.g.
the C_NewItems WorldTick bag scan), where the token resolver could have
raised.

Migrated: item/Count, item/AverageLevel, item/Swap, item/Location,
player/CanUseItem, spell/Cancel, equipmentset/Data, equipmentset/Locations,
merchant/Frame. debug/Probe keeps its own (deliberately standalone).
2026-07-16 23:17:24 -05:00
Brues 07a91feff0 item: add C_NewItems (new-item glow tracking)
Backport the modern C_NewItems system - IsNewItem / RemoveNewItem /
ClearAll - flagging items freshly added to bags as "new", cleared once
acknowledged. Vanilla has none of the machinery (no DBC, no server data),
so it is pure client-side bookkeeping, done entirely in C++ like retail.

Newness is keyed on each item's instance GUID (read off the CGItem), so a
flag survives the item being moved between slots - impossible for a Lua
addon, which can only track by (bag, slot). Bags 0..4 only; equipment and
bank are out of scope, matching retail.

The feed is event-driven, not polled: it subscribes to Bag::UpdateDelayed
(which co-hooks the engine's three BAG_UPDATE fire sites), so a bag rescan
runs only on frames where a bag actually changed. A one-time baseline taken
~1.5s after login (re-armed on character switch) keeps items owned at login
from being flagged. BAG_NEW_ITEMS_UPDATED fires when the set changes.

Consolidations along the way:
- New Bag::UpdateDelayed::AutoSubscribe exposes the authoritative
  "a bag changed this frame" signal as a C++ subscription.
- New Item::Location::ContainerInventory factors the CGContainer -> its
  inventory (vtable +0x10) dispatch hand-rolled in item/Count,
  item/AverageLevel, and equipmentset/Locations; all now share it.
- New Item::Location::EquippedBagInventory (equipped bag -> inventory) plus
  the FUN_GET_EQUIPPED_BAG_GUID / OFF_CONTAINER_GET_INVENTORY offsets.
- NewItems resolves the player via the shared Unit::Identity::PlayerObject.
2026-07-16 22:54:45 -05:00
Brues 88ea4356a0 creature: add UnitCreatureTypeID + C_CreatureInfo type lookups
Raw CreatureType id (CreatureType.dbc row: 1=Beast … 11=Totem), the
counterpart to the family additions:

  UnitCreatureTypeID(unit)              -> id, nil if none
  C_CreatureInfo.GetCreatureTypeInfo(id) -> { id, name }
  C_CreatureInfo.GetCreatureTypeIDs()    -> ascending id array (1..11)

UnitCreatureTypeID calls the engine's own type resolver at 0x00605570
(Script_UnitCreatureType's inner helper) directly — it handles all three
engine paths (display-override, creature-cache +0x18, player-race
ChrRaces+0x24), so it matches UnitCreatureType for every unit kind
(players resolve to Humanoid). The C_ lookups read CreatureType.dbc
(records 0x00C0DE2C, count 0x00C0DE30, name +0x04).

Also documented that 0x00605570 is really the creature-type resolver
(new canonical alias FUN_UNIT_CREATURE_TYPE); the existing
FUN_UNIT_PET_MINION_CLASS name is a historical label — Pet.cpp's
"!= 1" test works because it is effectively "is this a Beast (type 1)":
hunter pets are Beasts, warlock minions are Demons (3). Verified
in-game: target -> 7 Humanoid, IDs[8] -> 8 Critter, round-trips.
2026-07-16 21:20:47 -05:00
Brues 50909ce440 creature: add C_CreatureInfo.GetFactionInfo(raceID)
The by-raceID form of vanilla's UnitFactionGroup, returning
{ name (localized), groupTag (english "Alliance"/"Horde") } for a race
with no unit required. Mirrors the player branch of Script_UnitFactionGroup
(0x00516630) exactly: ChrRaces[race]+0x08 -> FactionTemplate id ->
FactionTemplate[id]+0x0C group mask -> scan FactionGroup.dbc for the row
whose bit the mask sets and whose localized name is non-empty (the
non-empty test is how the engine skips the always-set "Player" bit and
the nameless "Monster" row to land on Alliance/Horde). nil for
non-numeric / non-positive / unknown race or a race with no named group.

Every field offset ground-truthed against ChrRaces/FactionTemplate/
FactionGroup .dbc files. FactionGroup is stored contiguously (base +
i*0x30, direct field reads), so it's iterated by hand rather than via
DBC::Record. Its records-base/count globals (0x00C0DD5C / 0x00C0DD60)
are taken from the disassembly, which is authoritative over DBCs.md's
standard-shape tabulation for this table (0x00C0DD64 / 0x00C0DD68) --
verified in-game: race 1 -> Alliance, race 2 -> Horde.
2026-07-16 21:11:04 -05:00
Brues d48d003828 creature: add C_CreatureInfo.GetCreatureFamilyInfo / GetCreatureFamilyIDs
Retail backports over CreatureFamily.dbc (records 0x00C0DE7C, count
0x00C0DE80 — the globals Script_UnitCreatureFamily already indexes), on
the existing C_CreatureInfo table:

  GetCreatureFamilyInfo(id) -> { id, name, iconFile }
  GetCreatureFamilyIDs()    -> ascending array of populated family ids

Localized name at record +0x20 (via LocalizedField), icon at +0x44 (via
StringField). GetCreatureFamilyIDs skips the sparse unused rows
(10/13/14/22/...); each element round-trips with GetCreatureFamilyInfo.

Faithful deviation: retail's iconFile is a numeric fileID, but vanilla's
DBC stores the icon as a texture-path string, so iconFile is a string
(usable directly with SetTexture) and "" for families with no icon
(warlock pets). Verified in-game: 27 -> Wind Serpent + icon path, 15 ->
Felhunter + "", IDs array sparse, unused id 10 -> nil.
2026-07-16 21:02:01 -05:00
Brues d51f5f72a9 unit: add UnitCreatureFamilyID(unit)
Extension exposing the numeric CreatureFamily id (CreatureFamily.dbc
row) for a unit, the value addons want for stable checks — stock
UnitCreatureFamily only returns the locale-dependent display name.

It's the same id the engine's Script_UnitCreatureFamily (0x0051A310)
resolves internally: its family helper FUN_006055e0 returns
[unit+0xB30] ? [row+0x1C] : 0, then maps it through CreatureFamily.dbc
for the name. We stop at the id. Resolves the token via the same
FUN_RESOLVE_UNIT_TOKEN, returns nil for family 0 (players, non-beast
NPCs) and unsynced/unresolvable units — matching UnitCreatureFamily.
Shares UnitSubName's +0xB30 creature-cache read path. Verified in-game:
target (cat) -> 2, targetpet -> 2.
2026-07-16 20:52:31 -05:00
Brues 04efd4caf1 input: reconcile modifier state on focus regain
MODIFIER_STATE_CHANGED never fired for a modifier released while WoW was
in the background: WM_KEY* messages are only delivered to the focused
window, so the WH_GETMESSAGE hook never saw the release and the cached
bit stayed stuck down (also leaving IsLeft/RightShiftKeyDown etc. wrong).

Add a WH_CALLWNDPROC thread hook to catch WM_ACTIVATEAPP (sent, not
posted, so invisible to WH_GETMESSAGE). On app activation, resync the
cached 6-bit mask from GetAsyncKeyState (global physical key state,
unaffected by focus), firing MODIFIER_STATE_CHANGED for any bit that
drifted while WoW was away. Both the keyboard path and the resync now
funnel through a shared SetBit helper so the "only transitions fire"
invariant holds regardless of source.

Caveat (documented): the engine's native IsShiftKeyDown/IsControlKeyDown/
IsAltKeyDown bottom out at synchronous GetKeyState and share the blind
spot; they are not reconciled here — only ClassicAPI's L/R functions and
the event reflect the corrected state.
2026-07-16 20:18:43 -05:00
Brues 329d6b75ba creature: add C_CreatureInfo.GetRaceInfo / GetClassInfo
Retail backports over ChrRaces.dbc / ChrClasses.dbc, on the existing
C_CreatureInfo table. By-id lookups (no unit token needed, unlike
UnitRace/UnitClass) returning the localized name plus the non-localized
file token that round-trips with the unit APIs:

  GetRaceInfo(raceID)   -> { raceName, clientFileString, raceID }
  GetClassInfo(classID) -> { className, classFile, classID }

Reuses the already-verified DBC offsets (OFF_CHRRACES_* / OFF_CHRCLASSES_*),
StringField for the token and LocalizedField for the name. nil for
non-numeric / non-positive / unknown ids. Verified in-game: race 4 ->
Night Elf/NightElf, class 1 -> Warrior/WARRIOR, unknown id -> nil.
2026-07-16 18:32:35 -05:00
Brues 6a677ce447 taxi/map: capital city areaIDs + C_Map.GetMapAreaIDs
Taxi areaID resolution now tries the node's LOCATION part first
("Orgrimmar" in "Orgrimmar, Durotar") -- accepted only when it's a
mapped area whose WorldMapArea rect contains the node -- then the zone
suffix, then the geometric resolver. Capitals thus pin on their own city
map (Orgrimmar 1637, Undercity 1497) instead of the surrounding region,
while normal nodes (location is an unmapped subzone) still resolve to the
outdoor zone. Map::Area::PercentInZone is now containment-checked so a
candidate zone is taken only if it actually holds the point.

Add C_Map.GetMapAreaIDs() -> { [mapName] = areaID } from WorldMapArea:
the missing bridge (vanilla has no GetCurrentMapAreaID) for resolving a
browsed city/instance map -- which GetMapZones() doesn't enumerate -- to
its zone id. Keys on the WorldMapArea name (matches GetMapInfo()'s dir
for nearly all maps; a few misspelled Blizzard folders like "Ogrimmar"
differ, documented).

Verified in-game: Orgrimmar/Undercity flight masters pin on their city
maps; GetMapAreaIDs()["Orgrimmar"] = 1637.
2026-07-16 18:06:55 -05:00
Brues f6f88ab4ee taxi/map: accurate zone resolution + reachable flag
Zone assignment (Map::Area::ZonePercent, shared by GetTaxiNodesForMap
and GetAreaTriggerInfo) chose among overlapping WorldMapArea bounding
boxes by smallest rect, mis-assigning border points. Now resolve by the
zone whose DRAWN LANDMASS the point sits on -- WorldMapOverlay hit rects,
the runtime analog of an ADT area-grid lookup -- tie-broken by how deep
inside an overlay the point is (a node deep in its own zone's overlay
beats one clipping a neighbor's edge). Falls back to most-interior
bounding box when on no overlay. Fixes Ratchet, Un'Goro, Nijel's Point.

For taxi specifically, resolve areaID from the node's own name suffix
("..., Western Plaguelands" -> AreaTable, exact then word-prefix) --
authoritative, and right at borders geometry can't separate (Chillwind
Camp -> WPL, not the Alterac box it sits in). New Map::Area::PercentInZone
projects the position into the named zone; geometric resolver remains the
fallback and the area-trigger path.

Add a `reachable` field: a flight path leads to the node (in-degree > 0).
false marks departure-only nodes reachable only by other means -- druid
Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor) -- so
a consumer can hide them from a "where can I fly" map without a hardcoded
druid guess. BuildTaxiGraph tracks endpoint + destination sets in one pass.

Verified in-game: Nijel's->Desolace(405), Marshal's->Un'Goro(490),
Ratchet->Barrens(17), Chillwind->WPL(28).
2026-07-16 16:44:58 -05:00
Brues 17c928028e taxi: GetTaxiNodesForMap() with no arg returns all continents
Previously no-arg meant the currently-viewed continent; make it return
every flight master on every continent instead -- a one-call flight DB.
A numeric mapID still filters to that continent. Each entry already
carries its own mapID, and the mount + TaxiPath-endpoint filters apply
uniformly. Verified: no-arg 97, GetTaxiNodesForMap(0) 48.
2026-07-16 14:20:41 -05:00
Brues a47f38417f taxi: add C_TaxiMap.GetTaxiNodesForMap / GetAllTaxiNodes
GetTaxiNodesForMap(mapID) reads TaxiNodes.dbc: real flight masters on a
continent, faction-tagged, discovered or not -- the static DB pfQuest's
pfDB.meta.flight wants. Non-flight rows are filtered out: a node must
have a flight mount (horde @+0x38 / alliance @+0x3C -- drops transports/
boats/zeppelins) AND be a TaxiPath.dbc endpoint (drops Northshire Abbey,
spurious duplicate hub rows, standalone markers with no connecting
path), plus a guard for Blizzard's lone "Generic, World target"
placeholder. Returns retail nodeID/name/faction/position (continent
0-1) + ext mapID/x/y/z and derived areaID/mapX/mapY (zone-relative).

GetAllTaxiNodes([uiMapID]) wraps the open flight master's live node
list (VAR_TAXI_NODE_ARRAY stride 0x20: slot+0 = TaxiNodes record ptr,
+0x04/+0x08 = flight-map x/y; count VAR_TAXI_NODE_COUNT; type via
FUN_004dc4e0): slotIndex (TakeTaxiNode), name, nodeID, state
(Enum.FlightPathState). Empty when no taxi map is open.

Registers Enum.FlightPathFaction / Enum.FlightPathState (retail values).
Extracts the zone-percent projection into shared Map::Area::ZonePercent
+ adds ContinentPercent; refactors AreaTriggers onto ZonePercent.
Verified: EK 48 real masters (Northshire/EPL/transports gone), Stormwind
Alliance / Undercity Horde, continent position matches projection; live
24 nodes with Stormwind state=Current.
2026-07-16 14:00:18 -05:00
Brues 1282e3f6db map: add C_MapExplorationInfo explored/unexplored map textures
GetExploredMapTextures(areaID) backports the retail (9.0-era) call:
only overlays the local player has discovered. GetUnexploredMapTextures
is the ClassicAPI-extension complement (the reveal-over-fog pieces); its
inverse, C_Map.GetMapOverlays, still returns all overlays.

Refactor the overlay walk into a shared Map::Overlays::PushZoneOverlays
with a Filter {All, Explored, Unexplored}; GetMapOverlays is now the
All wrapper. "Explored" is computed per-areaID from the player's
explored-areas bitfield ([player+0xE68]+0xE6C, indexed by AreaTable
AreaBit +0x0C, gated by +0x28) -- mirrors the engine's discovered-
overlay rebuild FUN_004a67a0 -- so the areaID arg works for any zone,
not just the current view. Also add a fileDataIDs field (ordered tile
paths, retail's field name) to every overlay entry.

New offsets: OFF_AREATABLE_AREA_BIT, OFF_AREATABLE_EXPLORE_GATE,
OFF_PLAYER_EXPLORED_BITS. Verified: Elwynn explored(10) + unexplored(2)
== all(12).
2026-07-16 02:25:10 -05:00
Brues 4e4fad9196 map: add C_Map.GetAreaInfo / GetAreas
Surface AreaTable.dbc's localized name column. GetAreaInfo(areaID) is
the retail backport -- localized name for one zone/subzone id, nil for
bad input / no row. GetAreas() is an extension: the full { [areaID] =
name } map (empty-name rows omitted, round-trips with GetAreaInfo), the
piece name databases need for name->id / reverse search. Same "surface
the hidden DBC" pattern as GetAreaTriggers / GetMapOverlays.

AreaTable.dbc is a client DBC (always loaded, synchronous, localized),
so no WDB cold-cache concerns. Uses existing VAR_AREATABLE_* /
OFF_AREATABLE_NAMES wiring; no new offsets. Verified: (12) Elwynn
Forest, (9) Northshire Valley, GetAreas()[12] Elwynn Forest.
2026-07-15 23:48:15 -05:00
Brues 67063876da spell: add C_SpellBook.GetSkillLineRank / GetSkillLineName
GetSkillLineRank(skillLineID) returns the local player's curRank,
maxRank, modifier for a SkillLine.dbc id, or nil if unlearned -- keyed
by id directly instead of forcing addons to map id -> localized name and
string-match GetSkillLineInfo's by-name, by-position walk. Resolves the
id to the player's skill slot via FUN_SKILL_LINE_TO_SLOT, then reads the
CGPlayer +0xE68 skill table (the storage Player::CanUseItem's
require-skill gate already uses). GetSkillLineName(skillLineID) is the
id -> localized name half (pure SkillLine.dbc read, learned or not).

Both live in the existing Spell::SkillLine module; no new offsets.
Verified: Enchanting(333) 171/225/15, Tailoring(197) 261/300/0,
name(182)=Herbalism, unlearned line -> nil.
2026-07-15 23:02:42 -05:00
Brues b34368ef14 map: expose overlay hit rect in C_Map.GetMapOverlays
Each overlay now carries hitRectTop/Left/Bottom/Right (WorldMapOverlay.dbc
+0x34..+0x40) -- the tight clickable landmass bounds in world-map canvas
px (~1002x668), the subzone-center source map addons scrape into their
zone tables. Verified: areaID 12 overlay 8 (NorthshireValley, areaID 9)
-> hitRect 164/400/388/617.

Also stop dropping texture-less rows: a row with an areaID + hit rect but
no texture now appears with textureName == "", zero size, and an empty
tiles table (ipairs-safe), instead of being skipped. No-op on stock/Octo
data (every overlay has a texture) but matches the schema and the "every
overlay of a zone" contract.
2026-07-15 22:07:35 -05:00
Brues 4983c9fd83 map: expose overlay areaIDs in C_Map.GetMapOverlays
Each overlay table now carries areaID (primary AreaTable id revealed,
first nonzero) and areaIDs (the full nonzero list -- WorldMapOverlay.dbc
areaID[4]; 52 of 707 overlays span 2-3 subzones). Lets consumers map an
overlay to its subzone(s) / exploration state. Verified: overlay 1 of
areaID 85 -> areaID 157.
2026-07-15 21:30:19 -05:00
Brues 9e5114141e map: add C_Map.GetMapWorldSize; extract shared Map::Area resolver
Backport the retail C_Map.GetMapWorldSize (9.0): return a zone's world
size in yards as width, height, read from the WorldMapArea.dbc loc rect
(width = locLeft - locRight east-west, height = locTop - locBottom
north-south). Takes an AreaTable areaID, or defaults to the world map's
current view like GetMapOverlays. Verified: (85) -> 4518.75, 3012.5.

Extract the two WorldMapArea row resolvers (areaID -> row, current view
-> row) out of map/Overlays.cpp into a shared Map::Area helper; both
GetMapOverlays and GetMapWorldSize key off the same rows, so the two
ways of naming a zone now live in one place.
2026-07-15 21:14:03 -05:00
Brues 6be25d78e0 map: add C_Map.GetAreaTriggerInfo / GetAreaTriggers
Expose AreaTrigger.dbc (517 static trigger volumes: subzone-entry /
exploration / teleport), which the client loads but never surfaces to
Lua -- the reason map addons ship hand-scraped trigger tables. Same
"read the hidden DBC" pattern as C_Map.GetMapOverlays.

Each trigger returns both the authoritative raw geometry (mapID, world
x/y/z, radius, isBox + box length/width/height/yaw) and a derived
consumable form (areaID + mapX/mapY as a 0..100 zone-relative percent)
via the WorldMapArea rect, picking the smallest zone whose rect
contains the point. GetAreaTriggerInfo(id) returns one trigger;
GetAreaTriggers([mapID]) enumerates all, optionally filtered to a map.

Adds the AreaTrigger.dbc record offsets and the WorldMapArea mapID /
loc-rect offsets to Offsets.h.
2026-07-15 20:04:08 -05:00
Brues 06e68d04a8 docs: document C_Item.PickupItem
Add the API.md entry (input shapes, equipment-then-bags search, no-op
conditions, direct-cursor-primitive note) plus its TOC link, and the
README Item-row index entry.
2026-07-15 19:56:25 -05:00
58 changed files with 6230 additions and 463 deletions
+1 -1
View File
@@ -12,7 +12,6 @@ Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\String.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
@@ -23,6 +22,7 @@ Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
-2
View File
@@ -15,8 +15,6 @@
-- - 1.12 has no concept of an item GUID and no public item-lock API,
-- so those return nil / no-op.
C_Item.GetItemInfo = GetItemInfo
----------------------------------------------------------------------
Item = {}
@@ -0,0 +1,228 @@
-- Backport of the modern `PlayerLocation` / `PlayerLocationMixin` parameter
-- object (Blizzard_ObjectAPI). Like `ItemLocation`, it's a lightweight
-- descriptor that wraps one of several ways to identify a player — a GUID, a
-- unit token, a chat line, a /who index, a battlefield-scoreboard index, etc.
-- — so retail/Classic addon code can construct and inspect it unchanged.
--
-- It's pure table bookkeeping: every Create/Set/Get/Is is just fields, no
-- engine access. The GUID / unit / chatLine / whoIndex / battlefieldScore
-- kinds all map to things vanilla 1.12 genuinely has. The community, voice,
-- and Battle.net kinds have no vanilla producer, but they're kept as harmless
-- data holders so the mixin matches retail exactly for any code that builds
-- or reads them. Only the two methods that reach into systems 1.12 lacks —
-- `IsValid` (Club) and `IsBattleNetGUID` (account info) — are guarded so they
-- degrade gracefully instead of erroring on the missing `C_Club` /
-- `C_AccountInfo` namespaces.
local CreateFromMixins = CreateFromMixins
PlayerLocation = PlayerLocation or {}
PlayerLocationMixin = PlayerLocationMixin or {}
--[[static]] function PlayerLocation:CreateFromGUID(guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetGUID(guid)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromUnit(unit)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetUnit(unit)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromChatLineID(lineID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetChatLineID(lineID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattlefieldScoreIndex(battlefieldScoreIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattlefieldScoreIndex(battlefieldScoreIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromWhoIndex(whoIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetWhoIndex(whoIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromVoiceID(memberID, channelID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetVoiceID(memberID, channelID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattleNetID(battleNetID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattleNetID(battleNetID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityChatData(clubID, streamID, epoch, position)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityData(clubID, streamID, epoch, position)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityInvitation(clubID, guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityInvitation(clubID, guid)
return playerLocation
end
--[[public api]]
function PlayerLocationMixin:SetGUID(guid)
self:ClearAndSetField("guid", guid)
end
function PlayerLocationMixin:IsValid()
if self:IsCommunityData() then
-- Vanilla has no Community/Club system; if the namespace is
-- absent a community location can never be resolved.
if C_Club and C_Club.CanResolvePlayerLocationFromClubMessageData then
return C_Club.CanResolvePlayerLocationFromClubMessageData(self.communityClubID, self.communityStreamID, self.communityEpoch, self.communityPosition)
end
return false
end
return true
end
function PlayerLocationMixin:IsGUID()
return self.guid ~= nil
end
function PlayerLocationMixin:IsBattleNetGUID()
-- No Battle.net accounts on 1.12; guarded so it returns false instead
-- of erroring on a missing C_AccountInfo.
if self.guid and C_AccountInfo and C_AccountInfo.IsGUIDBattleNetAccountType then
return C_AccountInfo.IsGUIDBattleNetAccountType(self.guid)
end
return false
end
function PlayerLocationMixin:GetGUID()
return self.guid or self.communityClubInviterGUID
end
function PlayerLocationMixin:SetUnit(unit)
self:ClearAndSetField("unit", unit)
end
function PlayerLocationMixin:IsUnit()
return self.unit ~= nil
end
function PlayerLocationMixin:GetUnit()
return self.unit
end
function PlayerLocationMixin:SetChatLineID(lineID)
self:ClearAndSetField("chatLineID", lineID)
end
function PlayerLocationMixin:IsChatLineID()
return self.chatLineID ~= nil
end
function PlayerLocationMixin:GetChatLineID()
return self.chatLineID
end
function PlayerLocationMixin:SetBattlefieldScoreIndex(index)
self:ClearAndSetField("battlefieldScoreIndex", index)
end
function PlayerLocationMixin:IsBattlefieldScoreIndex()
return self.battlefieldScoreIndex ~= nil
end
function PlayerLocationMixin:GetBattlefieldScoreIndex()
return self.battlefieldScoreIndex
end
function PlayerLocationMixin:SetWhoIndex(whoIndex)
self:ClearAndSetField("whoIndex", whoIndex)
end
function PlayerLocationMixin:IsWhoIndex()
return self.whoIndex ~= nil
end
function PlayerLocationMixin:GetWhoIndex()
return self.whoIndex
end
function PlayerLocationMixin:SetVoiceID(memberID, channelID)
self:Clear()
self.voiceMemberID = memberID
self.voiceChannelID = channelID
end
function PlayerLocationMixin:IsVoiceID()
return self.voiceMemberID ~= nil and self.voiceChannelID ~= nil
end
function PlayerLocationMixin:GetVoiceID()
return self.voiceMemberID, self.voiceChannelID
end
function PlayerLocationMixin:SetBattleNetID(battleNetID)
self:Clear()
self.battleNetID = battleNetID
end
function PlayerLocationMixin:IsBattleNetID()
return self.battleNetID ~= nil
end
function PlayerLocationMixin:GetBattleNetID()
return self.battleNetID
end
function PlayerLocationMixin:SetCommunityData(clubID, streamID, epoch, position)
self:Clear()
self.communityClubID = clubID
self.communityStreamID = streamID
self.communityEpoch = epoch
self.communityPosition = position
end
function PlayerLocationMixin:IsCommunityData()
return self.communityClubID ~= nil and self.communityStreamID ~= nil and self.communityEpoch ~= nil and self.communityPosition ~= nil
end
function PlayerLocationMixin:SetCommunityInvitation(clubID, guid)
self:Clear()
self.communityClubID = clubID
self.communityClubInviterGUID = guid
end
function PlayerLocationMixin:IsCommunityInvitation()
return self.communityClubID ~= nil and self.communityClubInviterGUID ~= nil
end
--[[private api]]
function PlayerLocationMixin:Clear()
self.guid = nil
self.unit = nil
self.chatLineID = nil
self.battlefieldScoreIndex = nil
self.whoIndex = nil
self.voiceMemberID = nil
self.voiceChannelID = nil
self.communityClubID = nil
self.communityStreamID = nil
self.communityEpoch = nil
self.communityPosition = nil
self.communityClubInviterGUID = nil
self.battleNetID = nil
end
function PlayerLocationMixin:ClearAndSetField(fieldName, field)
self:Clear()
self[fieldName] = field
end
-13
View File
@@ -1,13 +0,0 @@
function string.match(s, pattern, init)
if s == nil then return nil end
local r1, r2, c1, c2, c3, c4, c5, c6, c7, c8, c9 =
string.find(s, pattern, init)
if r1 == nil then return nil end
if c1 ~= nil then
return c1, c2, c3, c4, c5, c6, c7, c8, c9
end
return string.sub(s, r1, r2)
end
string.gmatch = string.gfind
+21 -5
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.10)
project(ClassicAPI LANGUAGES CXX)
project(ClassicAPI LANGUAGES CXX RC)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -77,17 +77,20 @@ add_library(${PROJECT_NAME} SHARED
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(_MAJOR "${CMAKE_MATCH_1}")
set(_MINOR "${CMAKE_MATCH_2}")
set(_PATCH "${CMAKE_MATCH_3}")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${_MAJOR} * 10000 + ${_MINOR} * 100 + ${_PATCH}")
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
@@ -99,6 +102,19 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
# Stamp the PE VERSIONINFO resource from the same version components, so the
# DLL's file/product version (Explorer -> Properties -> Details) matches the
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
# untouched by the .rdata/.pdata section merges below.
set(CLASSICAPI_VER_STRING
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in"
"${CLASSICAPI_VERSION_RC}"
@ONLY)
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
+15 -11
View File
@@ -19,14 +19,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
@@ -41,28 +41,31 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.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` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [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_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -114,6 +117,7 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
+38
View File
@@ -0,0 +1,38 @@
// Generated from cmake/version.rc.in by CMake configure_file().
// Stamps the PE VERSIONINFO resource so Explorer -> Properties -> Details
// and GetFileVersionInfo() report the same version as the CLASSIC_API_VERSION
// Lua global. Both derive from the CLASSICAPI_TAG (vMAJOR.MINOR.PATCH) cache
// var; an untagged dev build gets the 9999.99.99 "newer than everything"
// sentinel.
#include <winver.h>
VS_VERSION_INFO VERSIONINFO
FILEVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
PRODUCTVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_DLL
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "ClassicAPI Team"
VALUE "FileDescription", "ClassicAPI - modern WoW API backport for 1.12.1"
VALUE "FileVersion", "@CLASSICAPI_VER_STRING@"
VALUE "InternalName", "ClassicAPI"
VALUE "LegalCopyright", "Licensed under GPLv3"
VALUE "OriginalFilename", "ClassicAPI.dll"
VALUE "ProductName", "ClassicAPI"
VALUE "ProductVersion", "@CLASSICAPI_VER_STRING@"
END
END
BLOCK "VarFileInfo"
BEGIN
// 0x0409 = U.S. English, 1200 = Unicode codepage.
VALUE "Translation", 0x409, 1200
END
END
+1297 -174
View File
File diff suppressed because it is too large Load Diff
+290 -7
View File
@@ -530,14 +530,23 @@ enum Offsets {
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// Returns 1 for a controllable **pet** (classified by creature family
// at descriptor +0x212 → family DBC, with a ChrRaces fallback), a
// non-1 value otherwise. The engine's unit-title builder `FUN_0052FD30`
// uses it as `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's
// Minion" — so it's the exact signal separating pet from minion, which
// neither the GUID (pets and guardians share the 0xF14… prefix) nor
// UNIT_FLAG_PLAYER_CONTROLLED reliably does.
// `0x00605570` is really the engine's **creature-type resolver** — it's
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
// `CreatureType.dbc` id (1=Beast, 3=Demon, 7=Humanoid, …), via three
// paths in order: a display-override at descriptor `+0x212`, then the
// creature-cache type at `[unit+0xB30]+0x18`, then the player-race type
// at `ChrRaces[[unit+0x110]+0x78]+0x24`. `FUN_UNIT_CREATURE_TYPE` is the
// canonical name; `FUN_UNIT_PET_MINION_CLASS` is a historical alias for
// the same address.
//
// The pet/minion use: the unit-title builder `FUN_0052FD30` calls it as
// `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's Minion". That
// works because it's really testing "is this a Beast (type 1)": hunter
// pets are Beasts, warlock minions are Demons (type 3) — so `!= 1`
// cleanly separates them, which neither the GUID (pets and guardians
// share the 0xF14… prefix) nor UNIT_FLAG_PLAYER_CONTROLLED reliably does.
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
FUN_UNIT_CREATURE_TYPE = 0x00605570,
// Party / raid roster counts and the party GUID array referenced
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
@@ -706,11 +715,19 @@ enum Offsets {
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
// +0x1C CreatureFamily id (→ CreatureFamily.dbc), 0 = none
//
// 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,
// CreatureFamily id within the +0xB30 cache row — the value the engine's
// `Script_UnitCreatureFamily` (`0x0051A310`) reads via its family helper
// `FUN_006055e0` (`[unit+0xB30] ? [row+0x1C] : 0`) before mapping it
// through `CreatureFamily.dbc` for the localized name. `UnitCreatureFamilyID`
// returns the raw id. Same field as `OFF_CREATURE_FAMILY` in the creature
// cache data block, reached through the unit here.
OFF_CREATURE_CACHE_FAMILY = 0x1C,
// 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
@@ -781,6 +798,19 @@ enum Offsets {
// mov edx, [eax + 0x110]
// movzx eax, byte ptr [edx + 0x7B]
OFF_UNIT_DESCRIPTOR_POWER_TYPE_BYTE = 0x7B,
// UNIT_FIELD_STAT0..4 — the fully-computed (base+item+buff) primary
// stats, broadcast in the descriptor. Order Str/Agi/Sta/Int/Spirit;
// STAT4 (Spirit) = fieldIndex 0x94 → +0x250 (from the UpdateField name
// table; cross-checked in-game: descriptor +0x250 read 173→182 when a
// +9-Spirit item was equipped). What GetStat(STAT_SPIRIT) returns
// server-side; used by GetSpellBonusHealing for the Spiritual-Guidance
// (aura 175) spirit→healing conversion.
OFF_UNIT_FIELD_STAT_SPIRIT = 0x250,
// UNIT_FIELD_RESISTANCES[0] = armor (the fully-computed total), fieldIndex
// 0x95 → +0x254 (7 resistances: armor, then the 6 magic schools). What
// GetArmor() / GetResistance(SPELL_SCHOOL_NORMAL) returns; used by
// GetSpellBonusHealing for Turtle's Ironclad (aura 199) armor→healing.
OFF_UNIT_FIELD_RESISTANCE_ARMOR = 0x254,
OFF_UNIT_FIELD_FLAGS = 0xA0,
UNIT_FLAG_PLAYER_CONTROLLED = 0x08,
// Bit 19 of UNIT_FIELD_FLAGS — `Script_UnitAffectingCombat` at
@@ -851,6 +881,27 @@ enum Offsets {
UNIT_AURA_TOTAL = 48,
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
// damage bonus. Offsets from the engine's own UpdateField name/index
// table in `.rdata` (20-byte entries `{char* name, u32 fieldIndex, u32
// count, ...}` at VA 0x83B6C8): POS = fieldIndex 0x3F5 count 7 → byte
// offset fieldIndex*4 = 0xFD4; NEG = 0x3FC → 0xFF0. Seven int32 each,
// one per school (0 physical .. 6 arcane); net bonus = POS − NEG.
//
// **Read these off `[player + OFF_CGPLAYER_INFO]` (the +0xE68 CGPlayer
// sub-struct), NOT the broadcast descriptor at +0x110.** The descriptor
// copy is never sent to the client and reads zero (verified in-game: the
// whole post-skills PLAYER descriptor region is zero on a geared L60 —
// vanilla never displayed spell power, so the server doesn't broadcast
// it). The +0xE68 struct holds the client's fully-computed value
// (gear + enchants + buffs + talents + sets baked in). Same base +
// offsets nampower's GetSpellPower uses; verified 613 generic / 647 Fire
// on a geared mage. Read by GetSpellBonusDamage in [src/spell/BonusDamage.cpp].
// (No MOD_HEALING_DONE equivalent — vanilla lacks the field entirely;
// GetSpellBonusHealing is derived instead.)
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS = 0xFD4,
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG = 0xFF0,
// Reusable "is this spell record a user-visible aura" predicate.
// `__fastcall(spellRecord*) -> bool`. Checks Spell.dbc Attributes
// (high bit of byte at +0x18 must be clear), AttributesEx bit
@@ -1207,8 +1258,33 @@ enum Offsets {
// locLeft/locRight/locTop/locBottom floats}
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
OFF_WMA_MAP_ID = 0x04,
OFF_WMA_AREA_ID = 0x08,
OFF_WMA_NAME = 0x0C,
OFF_WMA_LOC_LEFT = 0x10, // world Y at the map's left edge (max Y)
OFF_WMA_LOC_RIGHT = 0x14, // world Y at the map's right edge (min Y)
OFF_WMA_LOC_TOP = 0x18, // world X at the map's top edge (max X)
OFF_WMA_LOC_BOTTOM = 0x1C, // world X at the map's bottom edge (min X)
// AreaTrigger.dbc record (10 fields, 0x28 bytes) — instance cataloged
// in docs/DBCs.md (records slot 0x00C0E034, count 0x00C0E038). The
// client loads it but exposes NO geometry to Lua, which is why map
// addons (pfQuest et al.) ship hand-scraped trigger tables. Schema:
// {ID@+0, mapID@+4 (Map.dbc — continent or instance),
// x@+8, y@+0xC, z@+0x10 (continent-space world coords),
// radius@+0x14 (sphere trigger; 0 => box trigger),
// boxLength@+0x18, boxWidth@+0x1C, boxHeight@+0x20, boxYaw@+0x24}
VAR_AREATRIGGER_RECORDS = 0x00C0E034,
VAR_AREATRIGGER_COUNT = 0x00C0E038,
OFF_AT_MAP_ID = 0x04,
OFF_AT_X = 0x08,
OFF_AT_Y = 0x0C,
OFF_AT_Z = 0x10,
OFF_AT_RADIUS = 0x14,
OFF_AT_BOX_LENGTH = 0x18,
OFF_AT_BOX_WIDTH = 0x1C,
OFF_AT_BOX_HEIGHT = 0x20,
OFF_AT_BOX_YAW = 0x24,
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
@@ -1223,6 +1299,7 @@ enum Offsets {
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
OFF_WMO_WORLDMAP_AREA = 0x04,
OFF_WMO_AREA_ID = 0x08, // areaID[4] @ +0x08..+0x14 (AreaTable ids revealed)
OFF_WMO_MAP_POINT_X = 0x18,
OFF_WMO_MAP_POINT_Y = 0x1C,
OFF_WMO_TEXTURE_NAME = 0x20,
@@ -1230,6 +1307,10 @@ enum Offsets {
OFF_WMO_TEXTURE_HEIGHT = 0x28,
OFF_WMO_OFFSET_X = 0x2C,
OFF_WMO_OFFSET_Y = 0x30,
OFF_WMO_HITRECT_TOP = 0x34, // hit rect in world-map canvas px (~1002x668):
OFF_WMO_HITRECT_LEFT = 0x38, // the tight clickable bounds of the landmass,
OFF_WMO_HITRECT_BOTTOM = 0x3C, // used for subzone-center placement (pfQuest's
OFF_WMO_HITRECT_RIGHT = 0x40, // pfDB["zones"] was scraped from these)
// Current world-map view selection — the globals the engine's
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
@@ -1357,6 +1438,11 @@ enum Offsets {
// (see OFF_UNIT_FIELD_CHANNEL_SPELL below); PLAYER_FLAGS only
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// Explored-areas bitfield inside the CGPlayer info sub-struct
// (`[player+0xE68] + 0xE6C`): bit `AreaBit` set == that AreaTable area
// is explored. 0x100 bytes. Read by the discovered-overlay rebuild
// FUN_004a67a0 as `bits[AreaBit>>3] & (1 << (AreaBit&7))`.
OFF_PLAYER_EXPLORED_BITS = 0xE6C,
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
// the combo-point byte at +0x1029). Read by the item tooltip's
@@ -1487,6 +1573,25 @@ enum Offsets {
// Reads bagID at Lua stack[1] and slot at stack[2], validates them, and
// returns the inventory manager + linear slot ready to feed into GetItemBySlot.
FUN_PACK_BAG_SLOT = 0x004F9820,
// Equipped-bag container-GUID getter — `uint64 __fastcall(uint bagIndex0)`
// where `bagIndex0` is 0-based (Lua bagID 1..4 → 0..3; 4..9 are bank bags,
// gated on the bank-open globals). Returns the CGContainer GUID of the bag
// equipped in that slot, or 0 if none. This is the internal PackBagSlot
// uses to resolve bags 1..4: get the bag GUID here, resolve the container
// via FUN_OBJECT_RESOLVE_BY_GUID(OBJ_TYPE_CONTAINER, guid), then call the
// container's vtable[+OFF_CONTAINER_GET_INVENTORY] to get the inventory
// object GetItemBySlot indexes. Lets us enumerate bag contents in pure C++
// without PackBagSlot's Lua-stack coupling. (Reads GUID arrays at
// 0x00BDD060 lo / 0x00BDD064 hi, stride 8.)
FUN_GET_EQUIPPED_BAG_GUID = 0x004F93E0,
// CGContainer vtable slot (byte offset into the vtable) whose method
// returns the container's inventory object — `void* __thiscall(container)`.
// The inventory object's slot count lives at its +0x00 (OFF_INVMGR_SLOT_COUNT).
OFF_CONTAINER_GET_INVENTORY = 0x10,
// Backpack (bagID 0) linear-slot base in the player invMgr GUID/item
// arrays: a 1-based backpack slot S maps to linear (S - 1) + 23. Matches
// PackBagSlot's `*param_3 += 0x17` for the backpack branch.
BACKPACK_LINEAR_BASE = 23,
// Player inventory manager layout — used for direct GUID-array reads
// that bypass `GetItemBySlot`'s bank gate at `0x006228C1`.
// +0x00 uint32 max slot count
@@ -1733,6 +1838,16 @@ enum Offsets {
// last use"). Negative = single-use, destroyed when count hits 0.
// `GetItemCount(includeUses=true)` uses abs() for the multiplier.
OFF_DESCRIPTOR_SPELL_CHARGES_0 = 0x28,
// ITEM_FIELD_ENCHANTMENT block (fields 16..36, per the +0x48
// enchant-charges note above) — 7 enchant slots, 3 dwords each
// `{enchantID, duration, charges}`. Slot 0 = permanent enchant,
// slot 1 = temporary (weapon oils / sharpening stones). The
// enchant ID indexes SpellItemEnchantment.dbc. Block base = field
// 16 = +0x40 (field 15 FLAGS at +0x3C precedes it); slot stride
// 0x0C, enchant ID at slot+0x00. Used by spell/BonusDamage.cpp to
// fold enchant-granted spell power into GetSpellBonusDamage.
OFF_DESCRIPTOR_ENCHANTMENT_ID = 0x40,
DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE = 0x0C,
// ITEM_FIELD_DURABILITY (current) and ITEM_FIELD_MAXDURABILITY (max) live
// adjacent to each other in the descriptor as plain dwords. Verified in
// `Script_GetInventoryItemBroken` (`0x004C8590`): after resolving the
@@ -1741,6 +1856,12 @@ enum Offsets {
// no durability concept (consumables, materials) have both fields 0.
OFF_DESCRIPTOR_DURABILITY = 0xA0,
OFF_DESCRIPTOR_MAX_DURABILITY = 0xA4,
// Per-instance "readable text" id (letters / mail-scroll items carry a
// server-assigned text id here). Drives the `readable` return of
// `Script_GetContainerItemInfo` (`0x004F9670`): at `0x004F97C1` it reads
// `[descriptor+0x9C]` and, when non-zero, reports the slot as readable
// even when the static ItemStats page-text is 0. Field index 0x27.
OFF_DESCRIPTOR_READABLE_TEXT_ID = 0x9C,
// Per-item repair-cost helper used by Script_GameTooltip_SetInventoryItem
// (3rd return value). __fastcall(ecx = CGItem *) -> int copperCost.
@@ -2177,6 +2298,56 @@ enum Offsets {
OFF_CHRRACES_FILENAME = 0x3C,
OFF_CHRRACES_NAMES = 0x44,
// CreatureFamily.dbc — pet/beast family metadata. Standard 5-DWORD
// class shape (instance 0x00C0DE74; records 0x00C0DE7C, count
// 0x00C0DE80 — the same globals `Script_UnitCreatureFamily`
// (`0x0051A310`) bounds-checks/indexes). 72-byte stride, familyID-keyed
// and sparse (10/13/14/22/… are unused). Localized `Name[]` at +0x20
// (the engine reads `record + 0x20 + locale*4`), `IconFile` texture-path
// string at +0x44 (last field; empty for warlock-pet families). Backs
// `C_CreatureInfo.GetCreatureFamilyInfo` / `GetCreatureFamilyIDs`.
VAR_CREATUREFAMILY_RECORDS = 0x00C0DE7C,
VAR_CREATUREFAMILY_COUNT = 0x00C0DE80,
OFF_CREATUREFAMILY_NAMES = 0x20,
OFF_CREATUREFAMILY_ICON = 0x44,
// CreatureType.dbc — pet/NPC classification (1=Beast … 11=Totem;
// contiguous ids, 11 rows). Records 0x00C0DE2C, count 0x00C0DE30 (the
// globals `Script_UnitCreatureType` (`0x0051A280`) bounds-checks/indexes),
// localized `Name[]` at +0x04 (engine reads `record + 0x04 + locale*4`).
// Backs `C_CreatureInfo.GetCreatureTypeInfo` / `GetCreatureTypeIDs`;
// `UnitCreatureTypeID` returns the id `FUN_UNIT_CREATURE_TYPE` resolves.
VAR_CREATURETYPE_RECORDS = 0x00C0DE2C,
VAR_CREATURETYPE_COUNT = 0x00C0DE30,
OFF_CREATURETYPE_NAMES = 0x04,
// Race → faction group, mirroring the player branch of
// `Script_UnitFactionGroup` (`0x00516630`) — backs
// `C_CreatureInfo.GetFactionInfo(raceID)`. Chain:
// ChrRaces[race] + 0x08 → FactionTemplate id
// FactionTemplate[id] + 0x0C → group mask (ourMask)
// scan FactionGroup for the row whose bit is set (and whose
// localized name is non-empty — that non-empty test is what
// skips the always-set "Player" bit and lands on Alliance/Horde)
// push { groupTag = row+0x08 (english), name = row+0x0C+locale*4 }
// FactionTemplate is a pointer-array DBC (records[id]); FactionGroup
// is stored CONTIGUOUSLY (records-base + i*stride, direct field reads,
// NOT a pointer array), so iterate it by hand rather than via DBC::Record.
// FactionTemplate globals match docs/DBCs.md; the FactionGroup
// records-base/count globals are the ones the engine actually
// dereferences per the disassembly (0x00C0DD5C / 0x00C0DD60), which
// differ from DBCs.md's standard-shape tabulation for this table.
OFF_CHRRACES_FACTION_TEMPLATE = 0x08,
VAR_FACTIONTEMPLATE_RECORDS = 0x00C0DD3C,
VAR_FACTIONTEMPLATE_COUNT = 0x00C0DD40,
OFF_FACTIONTEMPLATE_GROUP_MASK = 0x0C,
VAR_FACTIONGROUP_RECORDS = 0x00C0DD5C,
VAR_FACTIONGROUP_COUNT = 0x00C0DD60,
FACTIONGROUP_STRIDE = 0x30,
OFF_FACTIONGROUP_BIT = 0x04,
OFF_FACTIONGROUP_ENGLISH = 0x08,
OFF_FACTIONGROUP_NAMES = 0x0C,
// AreaTable.dbc — used by `Script_GetCharacterInfo` to resolve
// each character's last-known-area to a localized zone name.
// Same shape as the other DBCs (records pointer + count globals,
@@ -2184,6 +2355,55 @@ enum Offsets {
VAR_AREATABLE_RECORDS = 0x00C0E048,
VAR_AREATABLE_COUNT = 0x00C0E04C,
OFF_AREATABLE_NAMES = 0x2C,
// AreaBit @ +0x0C: index into the player's explored-areas bitfield
// (OFF_PLAYER_EXPLORED_BITS). Gate @ +0x28: when < 0 the area needs no
// exploration (always counts explored); when >= 0 the AreaBit check
// applies. Source: the discovered-overlay rebuild FUN_004a67a0.
OFF_AREATABLE_AREA_BIT = 0x0C,
OFF_AREATABLE_EXPLORE_GATE = 0x28,
// TaxiNodes.dbc (16 fields, 0x40 bytes) — flight points. records/count
// per docs/DBCs.md. mapID @ +0x04 is the Map.dbc continent; x/y/z floats;
// Name[8] localized @ +0x14; the two MountCreatureID columns are
// [Horde @ +0x38, Alliance @ +0x3C] (0 = that faction has no flight
// master at this node — both 0 = a transport/system waypoint).
VAR_TAXINODES_RECORDS = 0x00C0D6C0,
VAR_TAXINODES_COUNT = 0x00C0D6C4,
OFF_TAXINODE_MAP_ID = 0x04,
OFF_TAXINODE_X = 0x08,
OFF_TAXINODE_Y = 0x0C,
OFF_TAXINODE_Z = 0x10,
OFF_TAXINODE_NAME = 0x14,
OFF_TAXINODE_HORDE_MOUNT = 0x38,
OFF_TAXINODE_ALLIANCE_MOUNT = 0x3C,
// Live taxi session — the open flight master's reachable node list,
// populated between TAXIMAP_OPENED/CLOSED (count 0 otherwise). Backs the
// legacy NumTaxiNodes/TaxiNodeName/TaxiNodePosition/TaxiNodeGetType Lua
// globals. Node slots are 0x20-stride records at VAR_TAXI_NODE_ARRAY:
// +0x00 = pointer to the node's TaxiNodes.dbc record (id @ +0, name @
// +0x14), +0x04 = flight-map x (0-1 float), +0x08 = y. Count at
// VAR_TAXI_NODE_COUNT. Derived from Script_TaxiNode* (FUN_004dc380 name,
// FUN_004dc3b0 position); the reachability type comes from
// FUN_TAXI_NODE_TYPE.
VAR_TAXI_NODE_COUNT = 0x00BB4AA8,
VAR_TAXI_NODE_ARRAY = 0x00BB4F10,
TAXI_NODE_STRIDE = 0x20,
OFF_TAXI_SLOT_RECORD = 0x00,
OFF_TAXI_SLOT_X = 0x04,
OFF_TAXI_SLOT_Y = 0x08,
// __fastcall(uint slot0Based) -> const char* ("CURRENT" / "REACHABLE" /
// "DISTANT" / other) — the node's reachability for the open session.
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
// cost}. A real flight master is a node some flight path connects to;
// Northshire Abbey / spurious duplicate rows / standalone markers are not
// endpoints and so are filtered out of GetTaxiNodesForMap.
VAR_TAXIPATH_RECORDS = 0x00C0D698,
VAR_TAXIPATH_COUNT = 0x00C0D69C,
OFF_TAXIPATH_FROM = 0x04,
OFF_TAXIPATH_TO = 0x08,
// Engine player-info cache — populated by the
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
@@ -2960,6 +3180,18 @@ enum Offsets {
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
// Player spell-knowledge writers — the two functions that mutate the
// bitmap above. Learn (`FUN_004b25b0`) sets the bit, rebuilds the
// spellbook arrays, and fires SPELLS_CHANGED; unlearn (`FUN_004b2c50`)
// clears the bit and removes the spell. Both `__fastcall`: learn is
// (uint spellID, int notify, uint replacedSpellID), unlearn is
// (uint spellID, int). Co-hooked (spell/Info.cpp) to bump
// Player::StatSignal so a talent respec invalidates GetSpellBonusHealing's
// talent-conversion cache. They fire at login (SMSG_INITIAL_SPELLS) and on
// each learn/unlearn — never per-frame, so cool hook targets.
FUN_LEARN_SPELL = 0x004B25B0,
FUN_UNLEARN_SPELL = 0x004B2C50,
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
@@ -3426,6 +3658,15 @@ enum Offsets {
// shape from SMSG_PARTY_MEMBER_STATS_FULL).
FUN_GROUP_MEMBER_STATS_LOOKUP = 0x00496400,
OFF_GROUP_MEMBER_AREA_ID = 0x14,
// `+0x08` status-flags byte of the stats block (see field map above);
// bit 0 is the online flag `Script_UnitIsConnected` tests. Sibling
// `FUN_GROUP_MEMBER_SLOT_LOOKUP` (`0x00496420`, `__fastcall(u64 *guid)
// → member slot*`) is the presence fallback UnitIsConnected consults
// when no stats block exists — a non-null slot means the member is
// present (and thus connected). Both take a pointer to the 64-bit GUID.
OFF_GROUP_MEMBER_STATUS_FLAGS = 0x08,
GROUP_MEMBER_STATUS_ONLINE = 0x1,
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
// Per-slot GUID tables — used to map party/raid token strings
// ("party1", "raid17") to GUIDs without going through
@@ -3555,6 +3796,27 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `str_find` — the Lua 5.0 string-library `string.find` C function
// (`int __fastcall(void *L)`), entry in the strlib luaL_reg table at
// `0x00822dd0`. Returns `1` (nil pushed, no match), `2` (start, end),
// or `2 + ncaptures`. `string.match` (a Lua 5.1 addition, missing here)
// shares the exact same pattern engine — it's `find` that yields the
// captures / whole match instead of the indices — so `BaseLib::StringLib`
// calls this and transforms the result rather than reimplementing the
// matcher. Verified by decoding the function at this address.
FUN_LUA_STR_FIND = 0x007FC3B0,
// `str_gfind` — the Lua 5.0 `string.gfind` C function (strlib entry at
// `0x00822dd8`). 5.1 renamed it to `string.gmatch` with identical
// behaviour (returns a stateful iterator closure over the matches), so
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `math_fmod` — the Lua 5.0 `math.mod` C function (mathlib entry at
// `0x00822cb0`): `luaL_checknumber(1/2)` → `fmod(x, y)` → `lua_pushnumber`.
// 5.1 renamed the Lua-facing name to `math.fmod` with the identical C
// implementation, so `BaseLib::MathLib` registers `math.fmod` as a direct
// alias of this function pointer — no wrapper. Verified by decode.
FUN_LUA_MATH_FMOD = 0x007FAFD0,
// `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
@@ -4717,8 +4979,27 @@ enum Offsets {
// Auction sell slot — holds a single CGItem GUID for the item
// currently sitting in the "sell" frame. Engine resolves it via
// a typed object lookup; pass `OBJECT_TYPE_ITEM` and the GUID.
// `Script_StartAuction` posts whatever GUID is here, so writing an
// arbitrary bag item's GUID + calling it posts that item — no cursor,
// no ClickAuctionSellItemButton. Used by `AuctionHouse::PostItem`.
VAR_AUCTION_SELL_GUID_LO = 0x00B72608,
VAR_AUCTION_SELL_GUID_HI = 0x00B7260C,
// Auctioneer NPC GUID — set while an auction house window is open;
// `Script_StartAuction` writes it as the packet target and bails if
// it's zero (so this doubles as an "AH is open" probe).
VAR_AUCTION_AUCTIONEER_GUID_LO = 0x00B725F8,
VAR_AUCTION_AUCTIONEER_GUID_HI = 0x00B725FC,
// `Script_StartAuction` (`0x004CE770`) — `int __fastcall(void *L)`;
// reads (minBid, buyout, runTime) from L[1..3], validates the sell
// slot + auctioneer + runTime, and sends CMSG_AUCTION_SELL_ITEM
// (opcode 0x256 = { auctioneerGuid, sellItemGuid, bid, buyout,
// runTime }). Reused verbatim by `AuctionHouse::PostItem` per stack.
FUN_SCRIPT_START_AUCTION = 0x004CE770,
// Valid `runTime` values `Script_StartAuction` accepts, in minutes:
// {120, 480, 1440} = 2h / 8h / 24h (vanilla's three durations). The
// modern `duration` arg (1/2/3) indexes this table.
VAR_AUCTION_DURATION_TABLE = 0x00807028,
AUCTION_DURATION_TABLE_COUNT = 3,
// `__fastcall(ECX = typeCode, EDX = unused, [stack] = guidLo,
// [stack] = guidHi, [stack] = anyInt) → CGObject *`.
// Returns NULL when no object matches or the type doesn't match.
@@ -4860,6 +5141,8 @@ enum Offsets {
// 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 = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
+427
View File
@@ -0,0 +1,427 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid,
// buyout)` — post `numStacks` auctions of `quantity` each from a single
// source stack, in one call. A ClassicAPI extension modelled on the retail
// multi-sell flow; vanilla's `StartAuction` posts exactly one item object.
//
// Why this is a tick-driven state machine (not a synchronous loop):
// vanilla's `CMSG_AUCTION_SELL_ITEM` (opcode 0x256) is
// `{ auctioneerGuid, itemGuid, bid, buyout, runTime }` — NO count field
// (WotLK added the server-side count; 1.12 has none, verified from
// `Script_StartAuction`). So the server posts the *entire* item object a
// GUID points at. To post a partial `quantity` out of a larger source, the
// client must first materialize that quantity as its own server-committed
// item object — i.e. split it into a bag slot (`CMSG_SPLIT_ITEM`) — and both
// the split and the resulting post are server-authoritative and async (the
// new stack's GUID doesn't exist client-side until `SMSG_UPDATE_OBJECT`
// lands). We drive the split→post→split→post sequence off `WorldTick`,
// keying each transition on observed bag state.
//
// Free-slot cost: a partial post needs at most ONE reused general-bag slot
// (split `quantity` off → post that stack → the post empties the slot →
// reuse it for the next split). The final stack (once the source is whittled
// to exactly `quantity`) posts the source object directly, no split. Posting
// a whole existing stack (`quantity` == the source count) needs zero free
// slots.
//
// Posting reuses the engine's `Script_StartAuction` verbatim (write the
// target stack's GUID into the sell-slot globals, push bid/buyout/runTime,
// call it) so all of its validation runs per stack — damaged-item reject,
// buyout normalization, runTime table check, auctioneer/"AH open" check.
//
// Events (retail multi-sell names, fired via Event::Custom):
// AUCTION_MULTISELL_START — job accepted, posting begins
// AUCTION_MULTISELL_UPDATE(done, total) — after each stack lands
// AUCTION_MULTISELL_FAILURE — aborted (source changed, no slot,
// AH closed, or a step timed out)
//
// Limitations (v1): source is the single stack at `itemLocation` (not
// aggregated across bags); temp splits use general-purpose bags only, so
// family-restricted items (arrows/shards in specialty bags) aren't
// supported; one job at a time.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
// (disconnect, server refusal we can't see) rather than normal latency.
constexpr int kMaxWaitTicks = 600;
enum class Phase { Idle, SplitWait, PostWait };
struct Job {
bool active;
int srcBag, srcSlot; // source stack (shrinks in place as we split off it)
int itemID; // guards against the source slot changing under us
int quantity;
int totalStacks;
int postedStacks;
int bid, buyout, runTime;
Phase phase;
int workBag, workSlot; // slot currently being split-into / posted-from
uint64_t postGuid; // GUID we posted — detect it leaving bags
int waitTicks;
};
Job g_job{};
// --- small CGItem / cache readers ---------------------------------------
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int CGItemCount(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
struct SlotInfo {
const uint8_t *item; // null = empty slot
int itemID;
int count;
uint64_t guid;
};
SlotInfo ReadSlot(void *L, int bag, int slot) {
SlotInfo r{nullptr, 0, 0, 0};
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr)
return r;
r.item = item;
r.itemID = Item::ID::FromCGItem(item);
r.count = CGItemCount(item);
r.guid = CGItemGuid(item);
return r;
}
// --- auction-house globals ----------------------------------------------
uint64_t AuctioneerGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
bool AHOpen() { return AuctioneerGuid() != 0; }
void SetSellGuid(uint64_t guid) {
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
static_cast<uint32_t>(guid);
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
static_cast<uint32_t>(guid >> 32);
}
// Point the sell slot at `guid` and fire the engine's own StartAuction for
// this bid/buyout/runTime. StartAuction reads its args from L[1..3]; we own
// the stack at frame-tail, so set top to 0, push the three, call, reset.
void PostStack(void *L, uint64_t guid) {
SetSellGuid(guid);
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(g_job.bid));
Game::Lua::PushNumber(L, static_cast<double>(g_job.buyout));
Game::Lua::PushNumber(L, static_cast<double>(g_job.runTime));
auto fn = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_START_AUCTION);
fn(L);
Game::Lua::SetTop(L, 0);
}
// --- free-slot discovery (general-purpose bags only) --------------------
bool BagIsGeneral(int bag) {
if (bag == 0)
return true; // backpack is always general
if (bag < 1 || bag > 4)
return false;
const uint8_t *bagItem =
Item::Location::ResolveEquipmentSlot(Offsets::INVSLOT_BAG1 + bag - 1);
if (bagItem == nullptr)
return false; // no bag equipped there
const int id = Item::ID::FromCGItem(bagItem);
if (id == 0)
return false;
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
}
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
for (int bag = 0; bag <= 4; ++bag) {
if (!BagIsGeneral(bag))
continue;
const int n = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= n; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr) {
*outBag = bag;
*outSlot = slot;
return true;
}
}
}
return false;
}
// --- state machine ------------------------------------------------------
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
void Tick() {
if (!g_job.active)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int savedTop = Game::Lua::GetTop(L);
if (!AHOpen()) { // window closed / walked away mid-job
Finish(false);
Game::Lua::SetTop(L, savedTop);
return;
}
switch (g_job.phase) {
case Phase::Idle: {
if (g_job.postedStacks >= g_job.totalStacks) {
Finish(true);
break;
}
const SlotInfo s = ReadSlot(L, g_job.srcBag, g_job.srcSlot);
if (s.item == nullptr || s.itemID != g_job.itemID || s.count < g_job.quantity) {
Finish(false); // source moved / consumed / insufficient
break;
}
if (s.count == g_job.quantity) {
// Final exact stack — post the source object directly, no split.
g_job.workBag = g_job.srcBag;
g_job.workSlot = g_job.srcSlot;
g_job.postGuid = s.guid;
PostStack(L, s.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs)) {
Finish(false); // no room to materialize the stack
break;
}
if (!Item::Swap::MoveCount(L, g_job.srcBag, g_job.srcSlot, fb, fs,
g_job.quantity)) {
Finish(false);
break;
}
g_job.workBag = fb;
g_job.workSlot = fs;
g_job.phase = Phase::SplitWait;
g_job.waitTicks = 0;
}
break;
}
case Phase::SplitWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
if (w.item != nullptr && w.itemID == g_job.itemID && w.count == g_job.quantity) {
g_job.postGuid = w.guid;
PostStack(L, w.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
case Phase::PostWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
}
Game::Lua::SetTop(L, savedTop);
}
const Tick::WorldTick::AutoSubscribe _tick{&Tick};
// --- Lua entry ----------------------------------------------------------
// Reads `loc.field` as an int. False if missing/non-numeric. Leaves the
// stack as found. (Same shape as Item::Location's file-static helper.)
bool ReadIntField(void *L, int locIdx, const char *field, int *out) {
Game::Lua::PushString(L, field);
Game::Lua::GetTable(L, locIdx);
const bool ok = Game::Lua::IsNumber(L, -1);
if (ok)
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
Game::Lua::SetTop(L, -2);
return ok;
}
// Map the modern `duration` arg to a valid engine runTime (minutes).
// Accepts the 1/2/3 index form and the raw {120,480,1440} value form.
// Returns 0 for anything else.
int MapDuration(int d) {
auto *table = reinterpret_cast<const uint32_t *>(Offsets::VAR_AUCTION_DURATION_TABLE);
if (d >= 1 && d <= Offsets::AUCTION_DURATION_TABLE_COUNT)
return static_cast<int>(table[d - 1]);
for (int i = 0; i < Offsets::AUCTION_DURATION_TABLE_COUNT; ++i)
if (static_cast<int>(table[i]) == d)
return d;
return 0;
}
// Push (false, reason) and return 2 — the immediate-rejection shape.
int Reject(void *L, const char *reason) {
Game::Lua::PushBool(L, false);
Game::Lua::PushString(L, reason);
return 2;
}
constexpr int kLuaTable = 5; // LUA_TTABLE (no named constant in Game::Lua)
int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
if (Game::Lua::Type(L, 1) != kLuaTable) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 4) || !Game::Lua::IsNumber(L, 5)) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
int bagID = 0, slotIndex = 0;
if (!ReadIntField(L, 1, "bagID", &bagID) ||
!ReadIntField(L, 1, "slotIndex", &slotIndex))
return Reject(L, "itemLocation must be a { bagID, slotIndex } table");
const int duration = static_cast<int>(Game::Lua::ToNumber(L, 2));
const int quantity = static_cast<int>(Game::Lua::ToNumber(L, 3));
const int numStacks = static_cast<int>(Game::Lua::ToNumber(L, 4));
const int bid = static_cast<int>(Game::Lua::ToNumber(L, 5));
const int buyout = Game::Lua::IsNumber(L, 6) ? static_cast<int>(Game::Lua::ToNumber(L, 6)) : 0;
const int runTime = MapDuration(duration);
if (runTime == 0)
return Reject(L, "invalid duration (use 1/2/3 or 120/480/1440)");
if (quantity < 1 || quantity > 255)
return Reject(L, "quantity must be 1..255");
if (numStacks < 1)
return Reject(L, "numStacks must be >= 1");
if (bid < 1)
return Reject(L, "bid must be >= 1");
if (!AHOpen())
return Reject(L, "auction house is not open");
if (g_job.active)
return Reject(L, "a multi-post is already in progress");
const SlotInfo s = ReadSlot(L, bagID, slotIndex);
if (s.item == nullptr)
return Reject(L, "source slot is empty");
const long need = static_cast<long>(quantity) * numStacks;
if (static_cast<long>(s.count) < need)
return Reject(L, "not enough items for numStacks x quantity");
// A partial post (source larger than one stack) needs one reusable
// general-bag slot to materialize each split. A whole-stack post
// (count == quantity, numStacks == 1) needs none.
if (s.count > quantity) {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs))
return Reject(L, "need at least one free general bag slot to split");
}
g_job = Job{};
g_job.active = true;
g_job.srcBag = bagID;
g_job.srcSlot = slotIndex;
g_job.itemID = s.itemID;
g_job.quantity = quantity;
g_job.totalStacks = numStacks;
g_job.postedStacks = 0;
g_job.bid = bid;
g_job.buyout = buyout;
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AuctionHouse", "PostItem",
&Script_C_AuctionHouse_PostItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AuctionHouse::PostItem
+13
View File
@@ -20,6 +20,7 @@
#include "Game.h"
#include "Offsets.h"
#include "net/PacketReader.h"
#include "player/StatSignal.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -288,6 +289,15 @@ int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
// player — buffs/debuffs move GetSpellBonusHealing's flat and Spirit/Armor
// terms, so its lazy cache must invalidate. Guarded on the player object so
// other units' aura churn (combat) doesn't needlessly invalidate it.
void NotifyIfPlayer(void *unit) {
if (static_cast<const uint8_t *>(unit) == Unit::Identity::PlayerObject())
Player::StatSignal::Notify();
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
@@ -297,6 +307,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraAdded{
@@ -317,6 +328,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
KindForSlot(slot));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraStacks{
@@ -346,6 +358,7 @@ void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
g_origOnAuraRemoved(unit, edx, slot, spellId);
(void)slot;
Evict(Unit::Identity::GuidForObject(unit), spellId);
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraRemoved{
+11
View File
@@ -56,6 +56,7 @@
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
@@ -71,8 +72,15 @@ const Event::Custom::AutoReserve _reserve{kEventName};
// Single bool, no atomics needed — the engine is single-threaded.
bool g_pending = false;
// C++ subscribers notified once per bag-change frame (see UpdateDelayed.h).
AutoSubscribe *g_subs = nullptr;
} // namespace
AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
g_subs = this;
}
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
// 10 bag slots, fires BAG_UPDATE for each that changed.
using BagSlotDiff_t = void(__cdecl *)();
@@ -123,6 +131,9 @@ void OnWorldTick() {
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
for (auto *node = g_subs; node != nullptr; node = node->next)
node->cb();
}
static const Game::HookAutoRegister _hookSlotDiff{
+39
View File
@@ -0,0 +1,39 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Authoritative "a bag changed this frame" subscription. `Bag::UpdateDelayed`
// co-hooks the three (and only three) engine `BAG_UPDATE` fire sites and
// coalesces them into a single per-frame signal — the same signal that backs
// the `BAG_UPDATE_DELAYED` event. Subscribe here to run C++ work exactly once
// per frame in which any bag actually changed, instead of polling every frame.
//
// Callbacks fire at the tail of the world tick, after every BAG_UPDATE for the
// frame has already fired (so bag contents are settled). No firing on frames
// where nothing changed. Order across subscribers is unspecified.
//
// Module pattern, matching `Tick::WorldTick::AutoSubscribe`: declare a
// file-scope `static const Bag::UpdateDelayed::AutoSubscribe _s{&Callback};`.
namespace Bag::UpdateDelayed {
using Callback = void (*)();
struct AutoSubscribe {
explicit AutoSubscribe(Callback cb);
Callback cb;
AutoSubscribe *next;
};
} // namespace Bag::UpdateDelayed
+47
View File
@@ -0,0 +1,47 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
//
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
//
// This is a pure rename: Lua 5.0 ships the exact same C function under the
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register `math.fmod` as a direct alias of the engine's `math.mod` C
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
// (we don't touch it), so code written for either name works.
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::MathLib {
namespace {
// `math.fmod` is 5.0's `math.mod` verbatim — register the engine function
// pointer directly rather than wrapping it.
const auto Script_math_fmod =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
}
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
// targets whichever state is active in each registration hook.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::MathLib
+87
View File
@@ -0,0 +1,87 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `select(index, ...)` — the Lua 5.1 base-library vararg helper, missing
// from 1.12's Lua 5.0 (it predates the function). Two forms:
// - `select('#', ...)` → the number of extra arguments.
// - `select(n, ...)` → all arguments from position `n` onward.
// `n` may be negative to index from the end (`-1` = the last arg).
//
// Ported from Lua 5.1's `luaB_select` (lbaselib.c). The numeric form needs
// no pushes at all: the varargs are already sitting on the stack at
// positions `2..top`, so returning `top - n` hands back exactly the slice
// from `n+1` onward — the same trick the reference implementation uses.
// The `'#'` form pushes the count and returns 1.
//
// Registered on BOTH Lua states (in-game + glue): it's a language-level
// helper, state-agnostic, and both states are 5.0 and equally missing it.
#include "Game.h"
namespace BaseLib::Select {
namespace {
int __fastcall Script_select(void *L) {
const int n = Game::Lua::GetTop(L);
if (n < 1) {
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected, got no value)");
return 0; // unreachable
}
// `select('#', ...)` — count form. Only inspect the string when the
// arg *is* a string: calling lua_tostring on a number would convert
// the stack value in place (Lua 5.0/5.1 semantics), corrupting arg 1.
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, 1);
if (s != nullptr && s[0] == '#') {
Game::Lua::PushNumber(L, static_cast<double>(n - 1));
return 1;
}
// A non-'#' string falls through: lua_isnumber accepts numeric
// strings (e.g. "2"), matching the reference's luaL_checkint
// coercion; a non-numeric string errors below.
}
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected)");
return 0; // unreachable
}
int i = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (i < 0)
i = n + i; // negative indexes count back from the last argument
else if (i > n)
i = n;
if (i < 1) {
Game::Lua::Error(L, "bad argument #1 to 'select' (index out of range)");
return 0; // unreachable
}
// The varargs from position i+1..n are already on the stack; returning
// their count hands exactly that slice back to the caller.
return n - i;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("select", &Script_select);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("select", &Script_select);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Select
+98
View File
@@ -0,0 +1,98 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction.
// - `string.gmatch(s, pattern)` — match iterator.
//
// Both are the same underlying pattern engine as functions 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
// start/end indices — so we call the engine's `str_find` and transform.
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
// it as a direct alias of the engine's `gfind` C function.
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
// every value except tables/userdata (both `lua_setmetatable` and the VM's
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
// no `__index`, and there's no client-side way to add one short of hooking a
// hot VM-core function. This is the long-standing vanilla constraint.
#include "Game.h"
#include "Offsets.h"
#include <cstddef>
namespace BaseLib::StringLib {
namespace {
// `string.match(s, pattern [, init])`. Calls the engine `str_find` and
// reshapes its result: no match → nil; match with captures → the captures;
// match with no captures → the whole matched substring.
int __fastcall Script_string_match(void *L) {
// Normalize to exactly (s, pattern, init): pad missing args with nil so
// str_find defaults `init` to 1, and drop any stray 4th value so it can't
// be misread as find's `plain` flag (match has no plain form).
Game::Lua::SetTop(L, 3);
constexpr int kBase = 3;
auto strFind = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FIND);
const int nres = strFind(L); // pushes start,end,caps.. above kBase, or a nil
if (nres <= 1) {
// No match. str_find pushed the nil (return 1); guard the degenerate
// nres==0 case by pushing our own nil so the shape holds.
if (nres < 1)
Game::Lua::PushNil(L);
return 1;
}
if (nres == 2) {
// No captures → the whole matched substring s[start..end] (1-based,
// inclusive). An empty match has end == start-1 → an empty string.
const int start = static_cast<int>(Game::Lua::ToNumber(L, kBase + 1));
const int end = static_cast<int>(Game::Lua::ToNumber(L, kBase + 2));
// str_find already coerced arg 1 to a string, so this is safe.
const char *s = Game::Lua::ToString(L, 1);
Game::Lua::SetTop(L, kBase); // drop start, end
const size_t len = (end >= start) ? static_cast<size_t>(end - start + 1) : 0;
Game::Lua::PushLString(L, s + start - 1, static_cast<unsigned int>(len));
return 1;
}
// Captures present → drop start & end, hand back only the captures.
Game::Lua::Remove(L, kBase + 1); // remove start; end → kBase+1, caps → kBase+2..
Game::Lua::Remove(L, kBase + 1); // remove end; caps → kBase+1..
return nres - 2;
}
// `string.gmatch` is 5.0's `string.gfind` verbatim — register the engine
// function pointer directly rather than wrapping it.
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
// targets whichever state is active in each registration hook.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::StringLib
+28
View File
@@ -130,11 +130,39 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
return 2;
}
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
// free slots across the player's *general-purpose* bags (backpack + equipped
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
// bags, …) are excluded, mirroring FrameXML's implementation exactly:
//
// for bag = BACKPACK .. lastBag:
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
// counted here — the client just doesn't carry the family bit for them. The
// result stays consistent with what `GetContainerNumFreeSlots` reports for
// those same bags.
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount > 0 && info.bagType == 0)
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
}
Game::Lua::PushNumber(L, static_cast<double>(total));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
&Script_C_Container_GetContainerNumFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+52
View File
@@ -0,0 +1,52 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.HasContainerItem(bagIndex, slotIndex)` — returns whether the
// given bag slot currently holds an item (a Classic Era addition). It's the
// occupancy sibling of `GetContainerItemID`: resolves the slot to its CGItem
// the same way (`Item::Location::ResolveBag` → engine `PackBagSlot` →
// `GetItemBySlot`), and a populated slot yields a non-null pointer while an
// empty slot yields null. Presence only — it does not depend on the item's
// static data being cached. Returns `false` for empty slots and invalid
// bag/slot indices.
#include "Game.h"
#include "item/Location.h"
namespace Container::HasItem {
namespace {
int __fastcall Script_C_Container_HasContainerItem(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_Container.HasContainerItem(bagIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const bool hasItem = Item::Location::ResolveBag(L, bagID, slotIndex) != nullptr;
Game::Lua::PushBool(L, hasItem);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "HasContainerItem",
&Script_C_Container_HasContainerItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::HasItem
+194
View File
@@ -0,0 +1,194 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.GetContainerItemInfo(containerIndex, slotIndex)` — the modern
// structured-table accessor (namespaced + table-returning since Patch
// 10.0.2). Vanilla's global `GetContainerItemInfo` returns a flat 5-tuple
// (`texture, itemCount, locked, quality, readable` — verified by decoding
// `Script_GetContainerItemInfo` at `0x004F9670`); backported addons expect
// the `ContainerItemInfo` table with the wider modern field set. We assemble
// that table from the same sources the engine reads plus the extra fields it
// never surfaced in 1.12.
//
// Field sources:
// iconFileID — ItemStats displayInfoID → ItemDisplayInfo.dbc icon path.
// (1.12 has no fileID system; we return the icon *path*
// string, same convention as `GetItemIcon`.)
// stackCount — CGItem descriptor `+OFF_DESCRIPTOR_STACK_COUNT` (the
// engine's `itemCount` return).
// isLocked — CGItem `+OFF_ITEM_CLIENT_LOCK & 1` (the `locked` return).
// quality — ItemStats `+OFF_ITEMSTATS_QUALITY`; nil when uncached
// (modern contract: `Enum.ItemQuality or nil`).
// isReadable — static page text OR the per-instance readable-text id,
// mirroring the engine's `readable` branch at `0x004F97BD`.
// hasLoot — ItemStats LOOTABLE flag (best-effort static approximation:
// vanilla can't track post-loot dynamic emptiness client-side).
// hyperlink — engine link builder via `Item::Link::FromCGItem` (enchant /
// random-suffix decorated, same as `GetContainerItemLink`).
// isFiltered — always false (vanilla has no bag search-filter system).
// hasNoValue — ItemStats sellPrice == 0.
// itemID — instance block itemID (`Item::ID::FromCGItem`).
// isBound — CGItem descriptor SOULBOUND flag (`C_Item.IsBound`'s bit).
// itemName — instance-decorated display name, base ItemStats name on miss.
//
// Returns nil for an empty slot or an invalid bag/slot index (modern
// contract). Cache-derived fields (icon / quality / hasLoot / hasNoValue /
// name) are omitted/nil when the item's static data isn't cached yet — the
// live per-instance fields (count / locked / bound / itemID / link) are always
// present. Passive: does not warm the item cache.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/StatAccum.h"
#include <cstdint>
#include <cstdio>
namespace Container::ItemInfo {
namespace {
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
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;
}
// Mirrors the engine's `readable` decision (0x004F97BD..0x004F97CF): a slot
// is readable when its static page text is set OR the per-instance
// readable-text id is non-zero (letters / server-authored notes).
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
if (record != nullptr &&
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
return true;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
return descriptor != nullptr &&
*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
}
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerItemInfo(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const uint8_t *item = Item::Location::ResolveBag(L, bagID, slotIndex);
if (item == nullptr) {
Game::Lua::PushNil(L); // empty slot / bad index → nil (modern contract)
return 1;
}
// --- Gather everything into locals BEFORE touching the Lua stack. The
// engine link/name builders and cache/DBC reads below are pure C; keeping
// them off the half-built table avoids any stack interleaving. ---
const int itemID = Item::ID::FromCGItem(item);
const uint8_t *record = (itemID > 0)
? Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID))
: nullptr;
// Live per-instance descriptor fields.
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
int stackCount = 0;
bool isBound = false;
if (descriptor != nullptr) {
stackCount = *reinterpret_cast<const int *>(
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
isBound = (*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
Offsets::ITEM_FLAG_SOULBOUND) != 0;
}
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
const bool isReadable = ItemReadable(item, record);
// Static template fields (cache-dependent).
bool haveIcon = false;
char iconPath[260];
bool haveQuality = false;
uint32_t quality = 0;
bool hasLoot = false;
bool hasNoValue = false;
if (record != nullptr) {
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
haveQuality = true;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
hasNoValue = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
}
const char *hyperlink = Item::Link::FromCGItem(item);
char nameBuf[256];
const char *itemName = nullptr;
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
itemName = nameBuf;
} else if (record != nullptr) {
const char *baseName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (baseName != nullptr && baseName[0] != '\0')
itemName = baseName;
}
// --- Build the ContainerItemInfo table (only stack-safe SetField* now). ---
Game::Lua::NewTable(L);
if (haveIcon)
Game::Lua::SetFieldString(L, "iconFileID", iconPath);
Game::Lua::SetFieldNumber(L, "stackCount", static_cast<double>(stackCount));
Game::Lua::SetFieldBool(L, "isLocked", isLocked);
if (haveQuality)
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
Game::Lua::SetFieldBool(L, "isReadable", isReadable);
Game::Lua::SetFieldBool(L, "hasLoot", hasLoot);
if (hyperlink != nullptr && hyperlink[0] != '\0')
Game::Lua::SetFieldString(L, "hyperlink", hyperlink);
Game::Lua::SetFieldBool(L, "isFiltered", false);
Game::Lua::SetFieldBool(L, "hasNoValue", hasNoValue);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
Game::Lua::SetFieldBool(L, "isBound", isBound);
if (itemName != nullptr)
Game::Lua::SetFieldString(L, "itemName", itemName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemInfo",
&Script_C_Container_GetContainerItemInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::ItemInfo
+245
View File
@@ -36,10 +36,24 @@
// Field offsets verified against the binary (rank read by the engine's
// classification helper as `[block+0x20]`) and real creaturecache.wdb
// rows. See Offsets.h `OFF_CREATURE_*`.
//
// This file also hosts the two static-DBC lookups on the same table:
//
// C_CreatureInfo.GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
// C_CreatureInfo.GetClassInfo(classID) -> { className, classFile, classID }
//
// retail backports over `ChrRaces.dbc` / `ChrClasses.dbc`. Unlike the
// creature cache these DBCs are always loaded, localized, and synchronous —
// so a by-id lookup works for any race/class id at any time, with no WDB
// cold-cache problem. The file-string columns (`clientFileString` /
// `classFile`) are the same non-localized tokens `UnitRace`/`UnitClass`
// return as their 2nd value ("NightElf", "WARRIOR"), so they round-trip
// with the unit APIs. See Offsets.h `OFF_CHRRACES_*` / `OFF_CHRCLASSES_*`.
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
@@ -122,11 +136,242 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
return 1;
}
// `C_CreatureInfo.GetRaceInfo(raceID)` — RaceInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrRaces row.
int __fastcall Script_GetRaceInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "clientFileString", file);
Game::Lua::SetFieldNumber(L, "raceID", raceID);
return 1;
}
// `C_CreatureInfo.GetClassInfo(classID)` — ClassInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrClasses row.
int __fastcall Script_GetClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "classFile", file);
Game::Lua::SetFieldNumber(L, "classID", classID);
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyInfo(creatureFamilyID)` — CreatureFamilyInfo
// table for a `CreatureFamily.dbc` row (1 = Wolf, 27 = Wind Serpent, …), or
// nil for a non-numeric / non-positive / unused id. `iconFile` is a texture
// path string here (retail exposes a numeric fileID; vanilla's DBC stores the
// path) — empty "" for families with no icon (e.g. warlock pets).
int __fastcall Script_GetCreatureFamilyInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int familyID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(familyID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
const char *icon = DBC::StringField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_ICON);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", familyID);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "iconFile", icon); // NULL/empty coerced to ""
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyIDs()` — array of every populated
// `CreatureFamily.dbc` id (sparse: skips the unused rows). Round-trips with
// GetCreatureFamilyInfo for each element.
int __fastcall Script_GetCreatureFamilyIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATUREFAMILY_NAMES);
if (name == nullptr)
continue; // unused row
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_CreatureInfo.GetFactionInfo(raceID)` — faction-group info for a race:
// { name = localized ("Alliance"/"Horde"/…), groupTag = english }. Mirrors
// the player branch of `Script_UnitFactionGroup` (`0x00516630`): resolve the
// race's FactionTemplate group mask, then scan `FactionGroup.dbc` for the row
// whose bit the mask sets and whose localized name is non-empty (which is what
// skips the always-set "Player" / the nameless "Monster" rows and lands on
// Alliance / Horde). nil for a non-numeric / non-positive / unknown race or a
// race with no named group. See Offsets.h for the full chain.
int __fastcall Script_GetFactionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (raceID <= 0)
return 0;
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return 0; // unknown race
const int ftId = *reinterpret_cast<const int *>(
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return 0;
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
Offsets::VAR_FACTIONTEMPLATE_COUNT,
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return 0;
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return 0;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = *reinterpret_cast<const int *>(
row + Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
const char *eng = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", loc);
Game::Lua::SetFieldString(L, "groupTag", eng);
return 1;
}
return 0; // no named group -> nil
}
// `C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)` — CreatureTypeInfo
// table for a `CreatureType.dbc` row (1 = Beast … 11 = Totem), or nil for a
// non-numeric / non-positive / unused id.
int __fastcall Script_GetCreatureTypeInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int typeID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(typeID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(typeID),
Offsets::OFF_CREATURETYPE_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", typeID);
Game::Lua::SetFieldString(L, "name", name);
return 1;
}
// `C_CreatureInfo.GetCreatureTypeIDs()` — array of every populated
// `CreatureType.dbc` id (contiguous 1..N in vanilla). Round-trips with
// GetCreatureTypeInfo for each element.
int __fastcall Script_GetCreatureTypeIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATURETYPE_NAMES);
if (name == nullptr)
continue;
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureInfoByID",
&Script_GetCreatureInfoByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "RequestLoadCreatureByID",
&Script_RequestLoadCreatureByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetRaceInfo",
&Script_GetRaceInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetClassInfo",
&Script_GetClassInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyInfo",
&Script_GetCreatureFamilyInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyIDs",
&Script_GetCreatureFamilyIDs);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetFactionInfo",
&Script_GetFactionInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeInfo",
&Script_GetCreatureTypeInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
+3 -11
View File
@@ -18,6 +18,7 @@
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -31,24 +32,15 @@ std::string g_path;
bool g_loaded = false;
bool g_ignoredForSave[SLOT_COUNT] = {};
void *ResolvePlayerInvMgr() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads the GUID currently in paperdoll slot `slot1Based` (1..19) by
// indexing the player invMgr's flat GUID array. Slot 1 (HEAD) lives
// at linear index 0; the equipment paperdoll occupies indices 0..18.
// Returns 0 for empty slots or unresolved player.
uint64_t ReadEquippedGUID(int slot1Based) {
auto *invMgr = static_cast<uint8_t *>(ResolvePlayerInvMgr());
const uint8_t *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr == nullptr)
return 0;
auto *guids = *reinterpret_cast<uint64_t **>(
auto *guids = *reinterpret_cast<uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return 0;
+5 -30
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -41,19 +42,6 @@ constexpr int LINEAR_BAG_SLOT_FIRST = 19; // bag1 → linear 19
constexpr int LINEAR_BACKPACK_FIRST = 23;
constexpr int LINEAR_BACKPACK_LAST = 38;
void *ResolvePlayer() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
uint8_t *ResolvePlayerInvMgr() {
auto *player = static_cast<uint8_t *>(ResolvePlayer());
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
uint64_t *InvMgrGuidArray(const uint8_t *invMgr) {
if (invMgr == nullptr)
return nullptr;
@@ -79,20 +67,6 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
0x172));
}
// CGContainer exposes its own `CContainerInventory *` via vtable
// `+0x10` — same dispatch `GetItemCount`'s bank walk uses. The
// returned invMgr has the standard shape: slot count at +0x00,
// flat GUID array at +0x04.
uint8_t *BagInvMgr(uint8_t *bag) {
if (bag == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<uint8_t **>(bag);
using GetInvMgr_t = void *(__thiscall *)(void *);
auto getInvMgr = reinterpret_cast<GetInvMgr_t>(
*reinterpret_cast<uintptr_t *>(vtable + 0x10));
return static_cast<uint8_t *>(getInvMgr(bag));
}
// Walks one bag's contents for a matching GUID. Returns the 1-based
// slot index of the match, or 0 if not found / bag unresolvable.
// Works for both player bags (linear 19..22 of player invMgr) and
@@ -102,7 +76,8 @@ int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
if (bag == nullptr)
return 0;
auto *bagInvMgr = BagInvMgr(bag);
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
return 0;
const int count = InvMgrSlotCount(bagInvMgr);
@@ -122,7 +97,7 @@ int FindGUID(uint64_t targetGuid) {
if (targetGuid == GUID_EMPTY || targetGuid == GUID_IGNORED)
return 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return 0;
@@ -180,7 +155,7 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
+11 -1
View File
@@ -28,7 +28,17 @@ namespace {
// any engine code runs. `RetryClaims` (triggered by the
// `Frame::RegisterEvent` hook) walks this list and claims a NULL slot
// for each unclaimed entry.
constexpr int MAX_RESERVED = 32;
//
// MUST stay comfortably above the total number of `AutoReserve` instances
// across the codebase (file-scope + the lazily-registered TTS ones) — the
// constructor SILENTLY drops any reservation past the cap, and which ones
// overflow depends on unspecified cross-TU static-init order, so exceeding
// it makes an arbitrary subset of custom events quietly stop firing (their
// `Lookup` returns -1 and `Fire` no-ops). We hit exactly that at 32: ~40
// reservations meant a shifting handful (e.g. ITEM_DATA_LOAD_RESULT /
// GET_ITEM_INFO_RECEIVED) never claimed a slot. Grep `AutoReserve` before
// bumping the reservation count near this ceiling.
constexpr int MAX_RESERVED = 64;
struct ReservedName {
const char *name;
int slot; // -1 until claimed
+3 -2
View File
@@ -37,8 +37,9 @@ namespace Event::Custom {
// DLL operate on the table independently of the others.
//
// The name pointer must outlive the engine (a string literal does).
// Same name reserved twice is deduped; reserving more than 32 names
// total silently drops the overflow.
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
// names total (see Custom.cpp) silently drops the overflow — keep that cap
// above the codebase-wide `AutoReserve` count.
struct AutoReserve {
explicit AutoReserve(const char *name);
};
+68 -19
View File
@@ -63,6 +63,14 @@ const char *const kKeyName[6] = {
"LSHIFT", "RSHIFT", "LCTRL", "RCTRL", "LALT", "RALT",
};
// L/R-specific virtual keys, one per bit, for the focus-regain resync.
// `GetAsyncKeyState` accepts the L/R-distinguishing VKs directly (unlike
// the keyboard-message path, where `VK_SHIFT` arrives merged and has to be
// disambiguated via the scancode).
const int kBitVk[6] = {
VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU,
};
constexpr const char *kModifierStateChanged = "MODIFIER_STATE_CHANGED";
// Static-init reservation — runs before DllMain. The
@@ -72,6 +80,7 @@ const Event::Custom::AutoReserve _reserveModifierStateChanged{kModifierStateChan
uint32_t g_modifierMask = 0;
HHOOK g_msgHook = nullptr;
HHOOK g_wndProcHook = nullptr;
void PushDown(void *L, bool down) {
if (down)
@@ -80,6 +89,28 @@ void PushDown(void *L, bool down) {
Game::Lua::PushNil(L);
}
// Apply a single modifier bit transition: update the cached mask and, on a
// genuine change, fire `MODIFIER_STATE_CHANGED`. Both the keyboard-message
// path and the focus-regain resync funnel through here so the "only
// transitions fire" invariant holds regardless of source.
void SetBit(int bitIdx, bool down) {
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
if (down == wasDown)
return;
if (down) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
}
template <int BitIdx>
int __fastcall Script_IsKeyDown(void *L) {
const bool down = (g_modifierMask & (1u << BitIdx)) != 0;
@@ -131,23 +162,22 @@ void ProcessKeyMessage(UINT msg, WPARAM wParam, LPARAM lParam) {
const int bitIdx = VkToBitIdx(wParam, lParam);
if (bitIdx < 0)
return;
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
const bool nowDown = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
if (nowDown == wasDown)
return;
SetBit(bitIdx, nowDown);
}
if (nowDown) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(nowDown));
// Reconcile the cached mask with the true physical key state. Needed on
// focus regain: keyboard messages only arrive while WoW has focus, so a
// modifier released while WoW was in the background is never seen by the
// `WH_GETMESSAGE` hook and the bit stays stuck down (and the release event
// never fires). `GetAsyncKeyState` reads global async keyboard state
// regardless of focus, so polling it on activation recovers the missed
// transitions — `SetBit` fires `MODIFIER_STATE_CHANGED` for each drift.
void ResyncFromPhysicalState() {
for (int b = 0; b < 6; ++b) {
const bool down = (GetAsyncKeyState(kBitVk[b]) & 0x8000) != 0;
SetBit(b, down);
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
@@ -164,11 +194,30 @@ LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
return CallNextHookEx(nullptr, code, wParam, lParam);
}
// `WM_ACTIVATEAPP` is *sent* (via the window manager), not *posted* to the
// queue, so `WH_GETMESSAGE` never sees it — a `WH_CALLWNDPROC` hook catches
// sent messages before the target WNDPROC runs. On app activation
// (`wParam != FALSE`) we resync from physical key state to recover any
// modifier released while WoW was in the background. Like the message hook,
// this is thread-level (not per-`HWND`), so it survives window recreation.
LRESULT CALLBACK CallWndProcHook(int code, WPARAM wParam, LPARAM lParam) {
if (code == HC_ACTION) {
const CWPSTRUCT *cwp = reinterpret_cast<const CWPSTRUCT *>(lParam);
if (cwp->message == WM_ACTIVATEAPP && cwp->wParam != FALSE)
ResyncFromPhysicalState();
}
return CallNextHookEx(nullptr, code, wParam, lParam);
}
void InstallHook() {
if (g_msgHook != nullptr)
return;
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
if (g_msgHook == nullptr) {
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
}
if (g_wndProcHook == nullptr) {
g_wndProcHook = SetWindowsHookExA(WH_CALLWNDPROC, &CallWndProcHook,
nullptr, GetCurrentThreadId());
}
}
void RegisterLuaFunctions() {
+5 -17
View File
@@ -55,6 +55,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
@@ -153,21 +154,11 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
// by reading the player's invMgr GUID array directly. Same shape as
// `C_Item.GetItemCount`'s bank path.
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
const uint8_t *PlayerInvMgr() {
auto resolveUnit = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolveUnit("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
@@ -229,7 +220,7 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
// player invMgr hold the bank bags themselves; each bag has its own
// invMgr accessed via the bag CGContainer's vtable+0x10 method).
void WalkBankBags(std::vector<Candidate> &candidates) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -242,11 +233,8 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
playerGuidArray[slot]);
if (bag == nullptr)
continue;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots =
@@ -295,7 +283,7 @@ static int __fastcall Script_GetAverageItemLevel(void *L) {
// session — GUIDs are populated from server data at login. Main
// bank first (linear slots 39..62 in the player invMgr), then
// each equipped bank bag's contents via the per-bag CGContainer.
const uint8_t *playerInvMgr = PlayerInvMgr();
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
WalkGuidArrayRange(playerInvMgr,
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
+5 -19
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -94,22 +95,12 @@ int CountInBag(void *L, int bagID, int targetItemID, bool includeUses) {
// object resolver `FUN_OBJECT_RESOLVE_BY_GUID` (the same function
// `GetItemBySlot` would call internally if the gate let us through).
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
const uint8_t *PlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
@@ -155,7 +146,7 @@ int CountInGuidArray(const uint8_t *invMgr, int firstSlot, int lastSlot,
// Layout: bag invMgr+0x00 = max slot count (the bag's size); bag
// invMgr+0x04 = its GUID array. Same shape as the player invMgr.
int CountInBankBags(int targetItemID, bool includeUses) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return 0;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -164,19 +155,14 @@ int CountInBankBags(int targetItemID, bool includeUses) {
return 0;
int total = 0;
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; slot++) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
playerGuidArray[slot]);
if (bag == nullptr)
continue;
// vtable +0x10 returns the bag's own invMgr pointer
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
@@ -251,7 +237,7 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
// Direct GUID-array reads — bypass the bank gate so this works
// without the bank window having ever been opened in the
// session.
total += CountInGuidArray(PlayerInvMgr(),
total += CountInGuidArray(Unit::Identity::PlayerInventoryManager(),
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
itemID, includeUses);
+83
View File
@@ -17,6 +17,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -245,7 +246,89 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
return 1;
}
// `C_Item.GetItemInfo(itemInfo)` — the full modern info tuple, sourced
// entirely from the cached `ItemStats_C` record (+ the class/subclass/invtype
// DBC name lookups this file already does for `GetItemInfoInstant`). Accepts
// a numeric itemID, an `"item:NNN"` string, or a full chat link — same parser
// as `GetItemInfoInstant`.
//
// Returns (18 values):
// itemName, itemLink, itemQuality, itemLevel, itemMinLevel, itemType,
// itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
// classID, subclassID, bindType, expansionID, setID, isCraftingReagent,
// itemDescription
//
// `expansionID` is always 0 (vanilla) and `isCraftingReagent` always false
// (no such flag in 1.12's item data); `setID` is nil when the item has no
// set. On a cache miss it warms the cache and returns nothing (nil) —
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0; // nil this call; GET_ITEM_INFO_RECEIVED fires when ready
}
auto u32 = [record](int off) {
return *reinterpret_cast<const uint32_t *>(record + off);
};
const uint32_t quality = u32(Offsets::OFF_ITEMSTATS_QUALITY);
const uint32_t itemLevel = u32(Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
const uint32_t reqLevel = u32(Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
const uint32_t classID = u32(Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = u32(Offsets::OFF_ITEMSTATS_SUBCLASS);
const uint32_t stackCount = u32(Offsets::OFF_ITEMSTATS_STACKABLE);
const uint32_t invType = u32(Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const uint32_t displayInfoID = u32(Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t sellPrice = u32(Offsets::OFF_ITEMSTATS_SELL_PRICE);
const uint32_t bindType = u32(Offsets::OFF_ITEMSTATS_BONDING);
const uint32_t setID = u32(Offsets::OFF_ITEMSTATS_ITEM_SET);
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(quality)); // 3 itemQuality
Game::Lua::PushNumber(L, static_cast<double>(itemLevel)); // 4 itemLevel
Game::Lua::PushNumber(L, static_cast<double>(reqLevel)); // 5 itemMinLevel
Game::Lua::PushString(L, LookupItemClassName(classID)); // 6 itemType
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID)); // 7 itemSubType
Game::Lua::PushNumber(L, static_cast<double>(stackCount)); // 8 itemStackCount
Game::Lua::PushString(L, LookupInvType(invType)); // 9 itemEquipLoc
Game::Lua::PushString(L, iconPath); // 10 itemTexture
Game::Lua::PushNumber(L, static_cast<double>(sellPrice)); // 11 sellPrice
Game::Lua::PushNumber(L, static_cast<double>(classID)); // 12 classID
Game::Lua::PushNumber(L, static_cast<double>(subClassID)); // 13 subclassID
Game::Lua::PushNumber(L, static_cast<double>(bindType)); // 14 bindType
Game::Lua::PushNumber(L, 0.0); // 15 expansionID (classic)
if (setID != 0)
Game::Lua::PushNumber(L, static_cast<double>(setID)); // 16 setID
else
Game::Lua::PushNil(L);
Game::Lua::PushBool(L, false); // 17 isCraftingReagent
Game::Lua::PushString(L, PushedOrEmpty(description)); // 18 itemDescription
return 18;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfo", &Script_C_Item_GetItemInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfoInstant", &Script_GetItemInfoInstant);
Game::Lua::RegisterGlobalFunction("GetItemIcon", &Script_GetItemIcon);
Game::Lua::RegisterTableFunction("C_Item", "GetItemIcon", &Script_C_Item_GetItemIcon);
+30 -11
View File
@@ -16,6 +16,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../unit/Identity.h"
#include "Arg.h"
#include "ID.h"
#include "Link.h"
@@ -29,20 +30,10 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *thisInvMgr, int slot);
using PackBagSlot_t = int(__fastcall *)(void *L, void **outInvMgr, int *outLinearSlot,
int *outUnused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
void *ResolveActivePlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads `loc.fieldName` and returns it as an int. Returns false via the
// boolean result if the field is missing or non-numeric. Always leaves the
// Lua stack as it found it.
@@ -84,7 +75,7 @@ uint64_t ReadCGItemGUID(const uint8_t *item) {
} // namespace
const uint8_t *ResolveEquipmentSlot(int slot1Based) {
void *invMgr = ResolveActivePlayerInvMgr();
void *invMgr = const_cast<uint8_t *>(Unit::Identity::PlayerInventoryManager());
if (invMgr == nullptr)
return nullptr;
// GetItemBySlot expects 0-based linearized slot indices. The built-in
@@ -95,6 +86,34 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based) {
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, slot1Based - 1));
}
void *ContainerInventory(const uint8_t *container) {
if (container == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(container);
using GetInventory_t = void *(__thiscall *)(void *container);
auto getInventory = *reinterpret_cast<const GetInventory_t *>(
vtable + Offsets::OFF_CONTAINER_GET_INVENTORY);
return getInventory(const_cast<uint8_t *>(container));
}
void *EquippedBagInventory(int bagID) {
if (bagID < 1 || bagID > 4)
return nullptr;
using GetBagGuid_t = uint64_t(__fastcall *)(uint32_t bagIndex0);
auto getBagGuid =
reinterpret_cast<GetBagGuid_t>(Offsets::FUN_GET_EQUIPPED_BAG_GUID);
const uint64_t bagGuid = getBagGuid(static_cast<uint32_t>(bagID - 1));
if (bagGuid == 0)
return nullptr; // no bag equipped in that slot
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto resolve = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *container = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_CONTAINER, "ItemMgr", static_cast<uint32_t>(bagGuid),
static_cast<uint32_t>(bagGuid >> 32), 0x172));
return ContainerInventory(container);
}
// Mirrors `Item::Bag::ResolveBagInfo`'s slot-count derivation: backpack
// is fixed at 16, equipped bags read `m_containerSlots` off the cache
// record. Returns 0 for empty bag slots or out-of-range bagIDs.
+22
View File
@@ -69,6 +69,28 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based);
// guaranteed cached by the engine's bag-sync flow at login.
int GetBagSlotCount(int bagID);
// Given a `CGContainer*` (an equipped bag or bank bag, resolved by GUID via
// `FUN_OBJECT_RESOLVE_BY_GUID` with `OBJ_TYPE_CONTAINER`), returns its own
// inventory-manager object via the container's vtable method at
// `+OFF_CONTAINER_GET_INVENTORY`. The returned object has the same layout as
// the player inventory manager: slot count at `+0x00`, flat GUID array at
// `+OFF_INVMGR_GUID_ARRAY`. Null-safe (null container → null). Pure C — no
// Lua stack, safe from any context. Consolidates the vtable dispatch that was
// hand-rolled across the bag/bank walks in `item/Count`, `item/AverageLevel`,
// `equipmentset/Locations`, and `item/NewItems`.
void *ContainerInventory(const uint8_t *container);
// Returns the inventory object of the equipped bag in player bag slot `bagID`
// (1..4): its container GUID via `FUN_GET_EQUIPPED_BAG_GUID`, resolved to the
// CGContainer, then its inventory via `ContainerInventory`. The result has the
// player-invMgr shape (slot count at `+0x00`, GUID array at
// `+OFF_INVMGR_GUID_ARRAY`) and is what `GetItemBySlot` indexes with a 0-based
// slot. Returns null for `bagID` outside 1..4 or when no bag is equipped
// there. Pure C — no Lua stack. (The backpack, `bagID` 0, has no container
// object; index it directly off the player inventory manager at
// `BACKPACK_LINEAR_BASE + slot - 1`.)
void *EquippedBagInventory(int bagID);
// GUID-walk result. `equipmentSlotIndex != 0` means the item was
// found in a character-pane equipment slot; otherwise it's in
// `bagID`/`slotIndex`. `item` always points to the found CGItem
+387
View File
@@ -0,0 +1,387 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_NewItems` — the modern "new item glow" bookkeeping system. Retail
// flags items freshly added to your bags as "new" (the sparkle/flash on
// the bag button) and clears the flag once you've acknowledged them.
// Vanilla 1.12 never had the feature — no DBC, no server data, no engine
// bit; it's pure client-side bookkeeping over bag contents. So we add it,
// entirely in C++ the same way retail's client owns it — no addon Lua.
//
// Public surface (matches retail exactly):
// C_NewItems.IsNewItem(bagID, slotIndex) -> bool
// C_NewItems.RemoveNewItem(bagID, slotIndex)
// C_NewItems.ClearAll()
//
// Newness is keyed on each item's **instance GUID** (read off the CGItem),
// not on (bagID, slotIndex). That's what makes a flag survive the player
// rearranging a bag — exactly like retail, and impossible for a pure-Lua
// addon (vanilla exposes no per-instance GUID to Lua).
//
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
// per frame in which a bag actually changed. On that signal we scan bags
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
// (no payload — matching retail) fires whenever the new-item set changes. So
// nothing runs on quiet frames.
//
// The one exception is establishing the login baseline: a lightweight
// WorldTick handler waits ~1.5s after the player is first resolvable (long
// enough for the initial bag population to finish), takes a single scan as the
// baseline, and then goes idle — items already owned at login are therefore
// never flagged. It re-arms on player-GUID change (relog / character switch).
//
// The "already owned" baseline spans **everything the player owns** — bags,
// worn equipment (paperdoll + equipped bags), AND the bank (main + bank bags)
// — even though only *bag* items are ever flagged new. That's what stops a
// move INTO a bag from any of those (unequip, or a pull from the bank) from
// looking like a fresh loot: the item's GUID was already in the owned set, so
// the bag diff treats it as a move. The bank is reachable without the bank
// window open because its GUIDs are read straight from the invMgr array (the
// same gate-bypass `C_Item.GetItemCount` uses; the array is server-populated
// at login).
//
// Bag enumeration is pure C++: it replicates what `PackBagSlot` does
// internally (see Offsets.h). The backpack is indexed straight off the
// player inventory manager; each equipped bag 1..4 is resolved to its
// container object and inventory via the same GUID getter + vtable call the
// engine uses, then walked with `GetItemBySlot`. None of the Lua-stack-bound
// helpers are touched, so the scan is safe from the frame-loop tick.
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Item::NewItems {
namespace {
constexpr const char *kEvtNewItems = "BAG_NEW_ITEMS_UPDATED";
const Event::Custom::AutoReserve _r{kEvtNewItems};
// Sized for the whole owned universe the baseline tracks — backpack + four
// bags + 19 worn slots + 4 equipped bags + main bank (24) + six bank bags.
// A full inventory-and-bank can push ~300, so 512 leaves generous headroom.
// Overflow silently drops (AddUnique bails at the cap) — a dropped GUID just
// can't be flagged/recognized, never a crash.
constexpr int kMaxItems = 512;
// Delay after the player is first resolvable before the baseline is taken,
// giving the initial bag population time to finish. Wall-clock (via the
// engine ms counter) so it's independent of frame rate.
constexpr uint32_t kSettleMs = 1500;
uint64_t g_seen[kMaxItems]; // all owned GUIDs (bags + equipment + bank) at the last scan
int g_seenCount = 0;
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
int g_newCount = 0;
bool g_seeded = false; // true once the login baseline has been taken
uint64_t g_playerGuid = 0; // re-arm the baseline when this changes (relog)
uint32_t g_loginMs = 0; // engine ms at which the current player resolved
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
bool Contains(const uint64_t *arr, int count, uint64_t guid) {
for (int i = 0; i < count; ++i)
if (arr[i] == guid)
return true;
return false;
}
bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
if (Contains(arr, *count, guid) || *count >= kMaxItems)
return false;
arr[(*count)++] = guid;
return true;
}
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
for (int i = 0; i < *count; ++i) {
if (arr[i] == guid) {
arr[i] = arr[--(*count)];
return true;
}
}
return false;
}
uint64_t ItemGUID(const uint8_t *item) {
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
// --- pure-C++ bag enumeration (no Lua stack) -----------------------------
using GetItemBySlot_t = void *(__thiscall *)(void *inventory, int slot0Based);
// Collects the instance GUIDs of every item in bags 0..4 into `out`.
// Returns the count. Pure C++ — safe to call off the frame-loop tick.
int ScanBags(const uint8_t *player, uint64_t *out) {
auto getItemBySlot =
reinterpret_cast<GetItemBySlot_t>(Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
int count = 0;
// Backpack (bagID 0): indexed directly off the player inventory manager
// at linear slot (S-1) + 23. GetItemBySlot only reads the manager, so the
// const_cast to satisfy its `this` parameter is safe.
auto *invMgr = const_cast<uint8_t *>(player) + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const int backpackSlots = Item::Location::GetBagSlotCount(0);
for (int slot = 1; slot <= backpackSlots; ++slot) {
auto *item = static_cast<const uint8_t *>(
getItemBySlot(invMgr, Offsets::BACKPACK_LINEAR_BASE + slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
// Equipped bags 1..4: resolve each bag's inventory object, then walk it.
for (int bagID = 1; bagID <= 4; ++bagID) {
const int slots = Item::Location::GetBagSlotCount(bagID);
if (slots <= 0)
continue;
void *bagInv = Item::Location::EquippedBagInventory(bagID);
if (bagInv == nullptr)
continue;
for (int slot = 1; slot <= slots; ++slot) {
auto *item = static_cast<const uint8_t *>(getItemBySlot(bagInv, slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
}
return count;
}
// Appends GUIDs from an invMgr's GUID array (the flat `+OFF_INVMGR_GUID_ARRAY`
// list `GetItemBySlot` indexes) over linear slots [first, last]. Reading the
// array directly is how we reach BANK slots without the bank window open —
// `GetItemBySlot` gates slots 39..68 on a live banker GUID, but the array
// itself is server-populated at login (same bypass `C_Item.GetItemCount`
// uses). We only need the GUID here, not the resolved object.
void AppendGuidArray(const uint8_t *invMgr, int first, int last, uint64_t *out,
int *count) {
if (invMgr == nullptr)
return;
auto *arr = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (arr == nullptr)
return;
for (int slot = first; slot <= last; ++slot)
if (arr[slot] != 0)
AddUnique(out, count, arr[slot]);
}
using ResolveByGuid_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(type, "ItemMgr", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0x172));
}
// Appends every item the player owns that ISN'T in bags 0..4 — worn
// equipment (paperdoll 1..19 + the four equipped-bag containers 20..23),
// the main bank (24 slots), and each bank bag's contents. Used only to widen
// the "already owned" baseline so a move INTO a bag from any of these
// (unequip, or a pull from the bank) reads as a move, not a fresh loot. We
// never *flag* these as new — this only feeds the seen-set. Pure C++ (no Lua
// stack) — safe off the frame tick.
void AppendNonBagOwned(uint64_t *out, int *count) {
// Worn equipment + equipped bags: GetItemBySlot works for these (no
// banker gate), so go through the normal per-slot resolver.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::INVSLOT_BAG1 + 3;
++slot) {
const uint64_t guid = ItemGUID(Item::Location::ResolveEquipmentSlot(slot));
if (guid != 0)
AddUnique(out, count, guid);
}
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
// Main bank slots 39..62 live in the player invMgr's own GUID array.
AppendGuidArray(playerInvMgr, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT, out, count);
// Bank bags (containers at player slots 63..68) each carry their own
// invMgr; resolve the container, then walk its GUID array.
auto *playerArr = *reinterpret_cast<const uint64_t *const *>(
playerInvMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (playerArr == nullptr)
return;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; ++slot) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER, playerArr[slot]);
if (bag == nullptr)
continue;
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
if (bagSlots > 0)
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
}
}
void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
}
void CopyToSeen(const uint64_t *current, int count) {
for (int i = 0; i < count; ++i)
g_seen[i] = current[i];
g_seenCount = count;
}
// Login-baseline handler. Runs every frame but does no work once the
// baseline is taken — it only watches for (re)login and, after a short
// settle, takes the single authoritative baseline scan. Steady state is just
// a GUID read + `if (g_seeded) return`.
void OnTick() {
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return; // not in world yet
// Fresh login / character switch: drop all state and re-arm the settle.
if (guid != g_playerGuid) {
g_playerGuid = guid;
g_newCount = 0;
g_seenCount = 0;
g_seeded = false;
g_loginMs = NowMs();
}
if (g_seeded)
return; // baseline established; the bag-change signal drives the rest
if (NowMs() - g_loginMs < kSettleMs)
return; // let the initial bag population finish
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently owned — bags AND worn equipment — becomes the
// baseline; nothing owned at login is "new". Including equipment means a
// later unequip (paperdoll → bag) is recognized as a move, not a loot.
g_seenCount = ScanBags(player, g_seen);
AppendNonBagOwned(g_seen, &g_seenCount);
g_newCount = 0;
g_seeded = true;
}
// Authoritative bag-change handler — fires only on frames where the engine
// actually changed a bag (via Bag::UpdateDelayed). Diffs the current bag set
// against the baseline, flags newly-acquired items, prunes departed ones.
void OnBagChanged() {
if (!g_seeded)
return; // pre-baseline changes are folded into the login baseline
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0 || guid != g_playerGuid)
return; // mid-relog; OnTick re-arms the baseline
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
uint64_t currentBags[kMaxItems];
const int bagCount = ScanBags(player, currentBags);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
for (int i = 0; i < g_newCount;) {
if (!Contains(currentBags, bagCount, g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
for (int i = 0; i < bagCount; ++i) {
if (!Contains(g_seen, g_seenCount, currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
CopyToSeen(currentBags, bagCount);
AppendNonBagOwned(g_seen, &g_seenCount);
if (changed)
FireChanged();
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
const Bag::UpdateDelayed::AutoSubscribe _bagChange{&OnBagChanged};
// --- C_NewItems Lua surface ----------------------------------------------
// Resolves a `(bagID, slotIndex)` Lua-arg pair to the item's instance GUID,
// or 0 if the args are missing/non-numeric or the slot is empty. Uses the
// Lua-context bag resolver — fine here, these run as Lua C functions.
uint64_t SlotGUID(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2))
return 0;
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
return ItemGUID(Item::Location::ResolveBag(L, bagID, slotIndex));
}
int __fastcall Script_IsNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
Game::Lua::PushBool(L, guid != 0 && Contains(g_new, g_newCount, guid));
return 1;
}
int __fastcall Script_RemoveNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
if (guid != 0 && RemoveFrom(g_new, &g_newCount, guid))
FireChanged();
return 0;
}
int __fastcall Script_ClearAll(void *L) {
(void)L;
if (g_newCount > 0) {
g_newCount = 0;
FireChanged();
}
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_NewItems", "IsNewItem", &Script_IsNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "RemoveNewItem", &Script_RemoveNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "ClearAll", &Script_ClearAll);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::NewItems
+39 -28
View File
@@ -279,6 +279,15 @@ void AddByKey(Accum *acc, const char *key, long delta) {
}
}
long Value(const Accum *acc, const char *key) {
if (acc == nullptr || key == nullptr)
return 0;
for (int i = 0; i < kCount; ++i)
if (std::strcmp(acc[i].key, key) == 0)
return acc[i].value;
return 0;
}
const uint8_t *FetchRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -306,6 +315,34 @@ double ComputeDPS(const uint8_t *record) {
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign) {
if (enchantID == 0)
return;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
return;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
@@ -316,34 +353,8 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s)
ApplyEnchant(acc, enchants[s], sign);
}
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
+10
View File
@@ -51,6 +51,9 @@ void Init(Accum *acc);
// a zero delta). Linear scan — the table is small and fixed.
void AddByKey(Accum *acc, const char *key, long delta);
// Read the accumulated value for `key` (0 for an unknown key). Linear scan.
long Value(const Accum *acc, const char *key);
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
// the cache (returns nullptr for an uncached item) — see the item-cache
// race note in CLAUDE.md.
@@ -66,6 +69,13 @@ void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
// suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
// Fold a single SpellItemEnchantment record (by enchant ID) into `acc`,
// scaled by `sign`. Handles equip-spell (type 3), resistance (4), and
// direct-stat (5) enchant effects. Shared by `ApplyRandomSuffix` (a suffix's
// enchant slots) and callers reading an item's *applied* permanent/temporary
// enchants off its CGItem descriptor. No-op for enchantID 0.
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign);
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
double ComputeDPS(const uint8_t *record);
+8 -13
View File
@@ -16,6 +16,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -23,7 +24,6 @@ namespace Item::Swap {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SwapFn_t = void(__thiscall *)(
void *player,
uint32_t srcItemLo, uint32_t srcItemHi,
@@ -33,11 +33,6 @@ using SwapFn_t = void(__thiscall *)(
uint32_t dstLinearSlot,
int flag);
void *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
// Reads the 64-bit GUID stored at instance-block offset 0 of any
// CGObject-derived pointer (CGItem, CGPlayer, CGContainer all use
// the same +0x8 → instance block → +0x00 GUID layout).
@@ -98,7 +93,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (slotInBag < 1)
return false;
if (bagID == 0) {
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -119,7 +114,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (bagID == -1) {
if (slotInBag > BANK_MAIN_NUM_SLOTS)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -152,7 +147,7 @@ bool SendSwap(const void *srcItem,
if (srcItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -187,7 +182,7 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -205,7 +200,7 @@ bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotIn
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -272,7 +267,7 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -308,7 +303,7 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot) {
if (srcItem == nullptr)
return false; // empty source slot
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
+251
View File
@@ -0,0 +1,251 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "map/Area.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
namespace Map::Area {
int RowForAreaID(uint32_t areaID) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec != nullptr &&
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
areaID)
return id;
}
return -1;
}
int CurrentViewRow() {
const int continent =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
if (continent < 0)
return *reinterpret_cast<const int *>(
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
if (blob == nullptr)
return -1;
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
const int zone =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
if (zone < 0)
return *reinterpret_cast<const int *>(entry + 0x04);
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
}
namespace {
double FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// Vanilla zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay
// hit rects live in this pixel space.
constexpr double kZoneCanvasW = 1002.0;
constexpr double kZoneCanvasH = 668.0;
// How deep inside its containing WorldMapOverlay hit rect the map-percent point
// (0..100) sits on WMA row `wmaRow` — the max, over that zone's overlays whose
// hit rect encloses the point, of the normalized margin to the nearest overlay
// edge (0 at the edge .. 0.5 at center). Returns -1 when the point is on none
// of the zone's overlays.
//
// This is the runtime analog of an ADT area-grid lookup. WorldMapArea rects are
// loose overlapping boxes; the overlay hit rects trace the real landmass, so
// "which zone is the point drawn on" disambiguates them (Un'Goro's crater vs
// Thousand Needles' empty overlap). But hit rects are themselves rectangles
// that overlap at borders, so the DEPTH matters: a border node deep inside its
// own zone's overlay beats one merely clipping a neighbor's overlay edge
// (Nijel's Point is deep in Desolace's "Nijel's Point" overlay but only nicks
// the edge of Stonetalon's Charred Vale box).
double OverlayMargin(int wmaRow, double mapXpct, double mapYpct) {
const double cx = mapXpct / 100.0 * kZoneCanvasW;
const double cy = mapYpct / 100.0 * kZoneCanvasH;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
double best = -1.0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_OVERLAY_RECORDS,
Offsets::VAR_WORLDMAP_OVERLAY_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr ||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
wmaRow)
continue;
const int hl = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_LEFT);
const int hr = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_RIGHT);
const int ht = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_TOP);
const int hb = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_BOTTOM);
const double sw = hr - hl, sh = hb - ht;
if (sw <= 0.0 || sh <= 0.0)
continue; // no / degenerate hit rect
if (cx < hl || cx > hr || cy < ht || cy > hb)
continue; // point outside this overlay
const double mX = (cx - hl < hr - cx ? cx - hl : hr - cx) / sw;
const double mY = (cy - ht < hb - cy ? cy - ht : hb - cy) / sh;
const double m = mX < mY ? mX : mY;
if (m > best)
best = m;
}
return best;
}
} // namespace
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
double *outMapY) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
// Two candidate tracks over the zones whose rect contains the point:
// land* — the point is on the zone's drawn landmass (an overlay hit rect
// encloses it): authoritative, wins whenever any zone qualifies.
// Scored by how DEEP inside the overlay the point sits, so a
// border node deep in its own zone's overlay beats one nicking a
// neighbor's overlay edge.
// box* — bounding-box only: the fallback when the point is on no zone's
// overlay (map gaps / overlay-less custom zones), scored by the
// most-interior zone-rect margin.
bool haveLand = false, haveBox = false;
double landScore = -1.0, boxMargin = 0.0;
int landArea = 0, boxArea = 0;
double landX = 0.0, landY = 0.0, boxX = 0.0, boxY = 0.0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
const int areaID =
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, not a zone
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom; // > 0
const double spanY = left - right; // > 0
if (spanX <= 0.0 || spanY <= 0.0)
continue; // degenerate / unmapped zone
if (x < bottom || x > top || y < right || y > left)
continue; // point outside this zone's rect
// mapX = horizontal (world Y), mapY = vertical (world X).
const double mapX = (left - y) / spanY * 100.0;
const double mapY = (top - x) / spanX * 100.0;
const double marginX = (x - bottom < top - x ? x - bottom : top - x) / spanX;
const double marginY = (y - right < left - y ? y - right : left - y) / spanY;
const double margin = marginX < marginY ? marginX : marginY;
if (!haveBox || margin > boxMargin) {
haveBox = true;
boxMargin = margin;
boxArea = areaID;
boxX = mapX;
boxY = mapY;
}
const double overlayMargin = OverlayMargin(id, mapX, mapY);
if (overlayMargin >= 0.0 && (!haveLand || overlayMargin > landScore)) {
haveLand = true;
landScore = overlayMargin;
landArea = areaID;
landX = mapX;
landY = mapY;
}
}
if (haveLand) {
*outAreaID = landArea;
*outMapX = landX;
*outMapY = landY;
return true;
}
if (haveBox) {
*outAreaID = boxArea;
*outMapX = boxX;
*outMapY = boxY;
return true;
}
return false;
}
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != areaID)
continue;
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom;
const double spanY = left - right;
if (spanX <= 0.0 || spanY <= 0.0)
continue; // degenerate row — keep scanning for a usable one
if (x < bottom || x > top || y < right || y > left)
return false; // point outside this zone's rect — not in this zone
*outMapX = (left - y) / spanY * 100.0; // horizontal (world Y)
*outMapY = (top - x) / spanX * 100.0; // vertical (world X)
return true;
}
return false;
}
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != 0)
continue; // want the continent-spanning row
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom;
const double spanY = left - right;
if (spanX <= 0.0 || spanY <= 0.0)
continue; // stray zero-rect continent row (e.g. "World") — skip
*outPx = (left - y) / spanY; // horizontal (world Y)
*outPy = (top - x) / spanX; // vertical (world X)
return true;
}
return false;
}
} // namespace Map::Area
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// `Map::Area` — shared `WorldMapArea.dbc` row resolution. A WorldMapArea
// row is the client's per-zone map descriptor (directory name + the
// world-coordinate rect used for world↔map transforms); every C_Map.*
// reader that keys off "which zone" resolves a row first. Both
// `Map::Overlays` (GetMapOverlays) and `Map::WorldSize` (GetMapWorldSize)
// go through here so the two ways of naming a zone — an AreaTable areaID
// argument, or the world map's current continent/zone view — live in one
// place.
namespace Map::Area {
// WorldMapArea row for an AreaTable `areaID` (the stable zone identity
// `C_Map.GetBestMapForUnit` returns). Linear walk of the ~175-row table.
// Returns -1 when no row carries that areaID.
int RowForAreaID(uint32_t areaID);
// WorldMapArea row for the world map's currently *viewed* zone — the same
// continent/zone selection the engine's map-name resolver (FUN_004a6cf0,
// behind GetMapInfo) performs: continent index → per-continent blob, zone
// index (-1 = whole continent) → that continent's zone-row array; no
// continent selected falls back to the default-row global. Returns -1 when
// the continent blob isn't resident.
int CurrentViewRow();
// Resolves world point (x, y) on continent `mapID` to a zone: its AreaTable
// areaID and a 0..100 zone-relative percent (`mapX` horizontal off world Y,
// `mapY` vertical off world X — the WoW/pfQuest convention).
//
// WorldMapArea rects are loose, overlapping bounding boxes, so a point near a
// zone edge falls inside several. Resolution therefore prefers the zone whose
// **drawn landmass** (WorldMapOverlay hit rect) the point actually sits on —
// the runtime analog of an ADT area-grid lookup (Un'Goro's crater vs Thousand
// Needles' empty overlap). Overlay hit rects are themselves rectangles that
// overlap at borders, so among landmass matches the winner is the zone the
// point is DEEPEST inside an overlay of (Nijel's Point is deep in Desolace's
// own overlay but only clips the edge of Stonetalon's Charred Vale box). If the
// point is on no zone's overlay, falls back to the most-interior containing
// rect. Returns false when no zone rect encloses the point (open sea,
// degenerate rect, an instance with no zone row). Shared by GetAreaTriggerInfo
// and the taxi-node zone resolve.
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
double *outMapY);
// Projects world (x, y) into the WorldMapArea rect of a SPECIFIC AreaTable
// zone (`areaID`), filling the 0..100 zone-relative percent (`mapX` horizontal
// off world Y, `mapY` vertical off world X). Returns false when that zone has
// no usable WorldMapArea row OR the point lies outside its rect. Used when the
// zone is already known by other means (a taxi node's name) and only the
// in-zone position is needed — bypassing the containment / landmass search of
// ZonePercent. The containment check lets a caller test candidate zones (a
// node's city vs its outdoor region) and take the first that actually holds
// the point.
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY);
// Projects world (x, y) on `mapID` to 0..1 CONTINENT-map coordinates using
// the continent's WorldMapArea row (the areaID == 0 row with a non-degenerate
// rect — some maps carry a stray zero-rect continent row that is skipped).
// `outPx` is horizontal (off world Y), `outPy` vertical (off world X).
// Returns false when the map has no usable continent row. This is the
// projection retail's TaxiNodeInfo.position uses.
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy);
} // namespace Map::Area
+169
View File
@@ -0,0 +1,169 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Map.GetAreaTriggerInfo(triggerID)` and `C_Map.GetAreaTriggers([mapID])`
// — expose `AreaTrigger.dbc`, the client's static trigger-volume geometry
// (subzone-entry / exploration / teleport triggers). Vanilla loads the DBC
// but exposes NONE of its geometry to Lua, so map addons (pfQuest, …) ship
// hand-scraped trigger tables. Same value proposition as `GetMapOverlays`:
// live, correct client data, no per-server table drift.
//
// ## Return shape (one info table)
//
// id — AreaTrigger.dbc row id
// mapID — Map.dbc id (continent 0/1, or an instance map)
// x, y, z — continent-space world coordinates of the trigger center
// radius — sphere-trigger radius (0 for a box trigger)
// isBox — true when the trigger is an oriented box (see below)
// boxLength / boxWidth / boxHeight / boxYaw — box dimensions + yaw
// (all 0 for a pure sphere trigger)
// areaID — AreaTable zone id the point falls in (via WorldMapArea);
// absent when no zone rect on this map contains the point
// mapX / mapY — the trigger's position as a 0..100 map-relative percent
// within that zone (the coordinate form map addons draw
// with); absent alongside areaID when unresolved
//
// The raw world geometry is authoritative (straight from the DBC); the
// derived `areaID`/`mapX`/`mapY` is the consumable form. Both are provided
// so a consumer can draw immediately or run its own world→map transform.
//
// ## World → map-percent transform
//
// `WorldMapArea.dbc` gives each zone a world-coordinate rect
// (`locTop`/`locBottom` bound world X, `locLeft`/`locRight` bound world Y —
// WoW's world axes: +X north, +Y west). In map space the HORIZONTAL axis
// (mapX) runs off world Y and the VERTICAL axis (mapY) off world X — the
// standard WoW / pfQuest / Astrolabe convention:
// mapX% = (locLeft - y) / (locLeft - locRight) * 100 (horizontal)
// mapY% = (locTop - x) / (locTop - locBottom) * 100 (vertical)
// We pick the zone by containment: among WorldMapArea rows on the trigger's
// map with a real (non-continent) areaID whose rect encloses the point, the
// smallest-area rect wins (the tightest zone — a subzone beats its parent).
// The engine's own map-name resolver reads the same rect fields; see
// `Map::Overlays` for the shared WorldMapArea wiring.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
namespace Map::AreaTriggers {
namespace {
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// Zone containment + map-percent projection lives in the shared
// `Map::Area::ZonePercent` (taxi nodes use the same transform).
// Builds one trigger's info table and leaves it on the Lua stack top.
// Returns false (pushing nothing) when `id` is out of range or the slot
// is empty.
bool PushTriggerInfo(void *L, int id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
Offsets::VAR_AREATRIGGER_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
const int mapID = *reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID);
const float x = FloatField(rec, Offsets::OFF_AT_X);
const float y = FloatField(rec, Offsets::OFF_AT_Y);
const float z = FloatField(rec, Offsets::OFF_AT_Z);
const float radius = FloatField(rec, Offsets::OFF_AT_RADIUS);
const float boxLength = FloatField(rec, Offsets::OFF_AT_BOX_LENGTH);
const float boxWidth = FloatField(rec, Offsets::OFF_AT_BOX_WIDTH);
const float boxHeight = FloatField(rec, Offsets::OFF_AT_BOX_HEIGHT);
const float boxYaw = FloatField(rec, Offsets::OFF_AT_BOX_YAW);
const bool isBox = (boxLength != 0.0f || boxWidth != 0.0f || boxHeight != 0.0f);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", id);
Game::Lua::SetFieldNumber(L, "mapID", mapID);
Game::Lua::SetFieldNumber(L, "x", x);
Game::Lua::SetFieldNumber(L, "y", y);
Game::Lua::SetFieldNumber(L, "z", z);
Game::Lua::SetFieldNumber(L, "radius", radius);
Game::Lua::SetFieldBool(L, "isBox", isBox);
Game::Lua::SetFieldNumber(L, "boxLength", boxLength);
Game::Lua::SetFieldNumber(L, "boxWidth", boxWidth);
Game::Lua::SetFieldNumber(L, "boxHeight", boxHeight);
Game::Lua::SetFieldNumber(L, "boxYaw", boxYaw);
int areaID = 0;
double mapX = 0.0, mapY = 0.0;
if (Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY)) {
Game::Lua::SetFieldNumber(L, "areaID", areaID);
Game::Lua::SetFieldNumber(L, "mapX", mapX);
Game::Lua::SetFieldNumber(L, "mapY", mapY);
}
return true;
}
// `C_Map.GetAreaTriggerInfo(triggerID)` — one trigger's info table, or nil
// for a missing / out-of-range id.
int __fastcall Script_GetAreaTriggerInfo(void *L) {
const bool hasID = Game::Lua::IsNumber(L, 1);
const int id = hasID ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
Game::Lua::SetTop(L, 0);
if (!hasID || !PushTriggerInfo(L, id))
Game::Lua::PushNil(L);
return 1;
}
// `C_Map.GetAreaTriggers([mapID])` — array of every trigger's info table,
// optionally filtered to a single Map.dbc id.
int __fastcall Script_GetAreaTriggers(void *L) {
const bool filter = Game::Lua::IsNumber(L, 1);
const int wantMap = filter ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATRIGGER_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
Offsets::VAR_AREATRIGGER_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (filter &&
*reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID) != wantMap)
continue;
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
PushTriggerInfo(L, id); // rec is valid, so this always pushes a table
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggerInfo",
&Script_GetAreaTriggerInfo);
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggers",
&Script_GetAreaTriggers);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::AreaTriggers
+133
View File
@@ -0,0 +1,133 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Map.GetAreaInfo(areaID)` and `C_Map.GetAreas()` — surface
// `AreaTable.dbc`'s localized name column to Lua. `AreaTable.dbc` is a
// client DBC (always loaded, synchronous, localized, ~1500 rows), so
// unlike creature/item names there's no WDB cold-cache problem — the name
// is always available for any id.
//
// - `GetAreaInfo(areaID)` is a retail backport: the localized name for one
// area id (zone or subzone), matching retail's signature.
// - `GetAreas()` is a ClassicAPI extension (retail uses the uiMapID tree,
// not raw AreaTable enumeration): the full id→name map, the load-bearing
// piece for name databases that need name→id / the full id set (reverse
// search, name resolution). Same "surface the hidden DBC" pattern as
// `GetAreaTriggers` / `GetMapOverlays`.
//
// Names are the client's active locale (same source as `GetRealZoneText` /
// `Script_GetCharacterInfo`'s zone-name resolve), so they match what the
// game shows everywhere — no locale/rename drift vs a shipped table.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
namespace Map::Areas {
namespace {
// `C_Map.GetAreaInfo(areaID)` — localized AreaTable name, or nil for
// non-numeric / non-positive input or an id with no row / empty name.
int __fastcall Script_GetAreaInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int areaID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(areaID > 0) ? DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(areaID),
Offsets::OFF_AREATABLE_NAMES)
: nullptr;
if (name == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_Map.GetAreas()` — { [areaID] = name } for every row with a non-empty
// localized name. Empty-name rows (DBC::LocalizedField → nullptr) are
// omitted, so the map round-trips with GetAreaInfo for every key.
int __fastcall Script_GetAreas(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_AREATABLE_NAMES);
if (name == nullptr)
continue;
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::PushString(L, name);
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_Map.GetMapAreaIDs()` — { [mapName] = areaID } for every
// `WorldMapArea.dbc` row: the map's name (the dir the engine uses for its map
// textures) → its `AreaTable` areaID. Vanilla has no `GetCurrentMapAreaID`;
// this lets an addon resolve a *browsed* map to a zone id — including the
// city / instance maps `GetMapZones()` doesn't enumerate (Orgrimmar,
// Undercity, Alterac Valley), which otherwise can't be matched to zone content
// (e.g. a flight master pinned by areaID). Continent-spanning rows (areaID 0)
// and unnamed rows are omitted.
//
// Note: this is the WorldMapArea NAME, which matches `GetMapInfo()`'s returned
// dir for almost all maps but not a few misspelled Blizzard folders (the
// Orgrimmar map's texture folder is "Ogrimmar"), so `[GetMapInfo()]` can miss
// for those — resolve them by zone name instead.
int __fastcall Script_GetMapAreaIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
const int areaID = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, no zone id
const char *dir = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_WMA_NAME);
if (dir == nullptr || dir[0] == '\0')
continue;
Game::Lua::SetFieldNumber(L, dir, static_cast<double>(areaID));
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetAreaInfo", &Script_GetAreaInfo);
Game::Lua::RegisterTableFunction("C_Map", "GetAreas", &Script_GetAreas);
Game::Lua::RegisterTableFunction("C_Map", "GetMapAreaIDs", &Script_GetMapAreaIDs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Areas
+80
View File
@@ -0,0 +1,80 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_MapExplorationInfo.GetExploredMapTextures(areaID)` and
// `GetUnexploredMapTextures(areaID)` — the exploration-filtered views of a
// zone's `WorldMapOverlay.dbc` entries.
//
// - `GetExploredMapTextures` is the retail-named backport: only the overlays
// the local player has **discovered**. Retail keys on `uiMapID` and returns
// numeric `fileDataIDs`; here it's an `AreaTable` areaID and the tiles are
// texture paths (`fileDataIDs` holds those paths, see Overlays.cpp).
// - `GetUnexploredMapTextures` is the ClassicAPI-extension complement: the
// overlays NOT yet discovered — what a map-reveal addon draws over the
// fogged base map. No retail equivalent (retail only ships the explored
// getter); its inverse, `C_Map.GetMapOverlays`, returns *all* overlays.
//
// All three share `Map::Overlays::PushZoneOverlays` — same per-overlay table
// shape (texture, resolved tiles, hit rect, areaIDs, fileDataIDs); these two
// just pass an exploration Filter. "Explored" is computed per-areaID from the
// player's explored-areas bitfield (see Overlays.cpp `IsOverlayExplored`), so
// the areaID argument works for any zone, not just the one on screen.
//
// With no argument, both default to the world map's currently viewed zone
// (like `GetMapOverlays()`).
#include "Game.h"
#include "map/Area.h"
#include "map/Overlays.h"
#include <cstdint>
namespace Map::Exploration {
namespace {
int ResolveRow(void *L) {
return Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
}
int __fastcall Script_GetExploredMapTextures(void *L) {
const int row = ResolveRow(L);
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Explored);
return 1;
}
int __fastcall Script_GetUnexploredMapTextures(void *L) {
const int row = ResolveRow(L);
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Unexplored);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
"GetExploredMapTextures",
&Script_GetExploredMapTextures);
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
"GetUnexploredMapTextures",
&Script_GetUnexploredMapTextures);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Exploration
+181 -71
View File
@@ -80,23 +80,37 @@
// Array of overlay tables:
// textureName — bare DBC name ("GOLDSHIRE")
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
// areaID — primary AreaTable id revealed (first nonzero)
// areaIDs — all AreaTable ids revealed (WorldMapOverlay.dbc
// areaID[4]; usually one, some overlays span 2-3 subzones)
// textureWidth / textureHeight — the DBC placement rect (reliable for
// placement; its IMPLIED tile count is what lies)
// offsetX / offsetY — placement on the zone canvas
// mapPointX / mapPointY
// hitRectTop/Left/Bottom/Right — hit rectangle in world-map canvas px
// (~1002x668); the tight clickable bounds of the landmass,
// the subzone-center source map addons scrape
// tileCols / tileRows / upscaled — the resolved grid
// tiles — array of ready-to-draw tiles:
// { file — texture path incl. number (SetTexture-ready)
// width, height — draw size in map pixels
// texCoordX, texCoordY — right/bottom texcoords
// offsetX, offsetY } — absolute canvas position
// fileDataIDs — ordered tile texture paths (retail's field name; vanilla
// has no numeric fileDataIDs, so these are paths). Redundant
// with tiles[].file — for retail-ported exploration addons.
//
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
// Texture-less rows (a subzone hit region with no distinct art) still appear,
// with textureName == "", zero size, and an empty `tiles` table — so
// geometry consumers see every subzone while `ipairs(ov.tiles)` draw loops
// stay safe. A consumer draws a textured overlay with a dumb loop: per tile
// SetTexture, SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include "map/Overlays.h"
#include <cstdint>
#include <cstdio>
@@ -279,46 +293,8 @@ const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
return g_cache[overlayId];
}
// WorldMapArea row id for the world map's current view — the same
// resolution the engine's current-map-name getter (FUN_004a6cf0, behind
// GetMapInfo) performs: continent/zone selection indexes into the
// per-continent data blob; no continent selected falls back to the
// default row global.
int CurrentWorldMapAreaRow() {
const int continent =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
if (continent < 0)
return *reinterpret_cast<const int *>(
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
if (blob == nullptr)
return -1;
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
const int zone =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
if (zone < 0)
return *reinterpret_cast<const int *>(entry + 0x04);
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
}
// WorldMapArea row id for an AreaTable areaID. Linear walk — the table
// has ~175 rows; called once per Lua call.
int WorldMapAreaRowForAreaID(uint32_t areaID) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec != nullptr &&
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
areaID)
return id;
}
return -1;
}
// WorldMapArea row resolution (areaID → row, current view → row) lives in
// the shared `Map::Area` helper — `GetMapWorldSize` keys off the same rows.
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
@@ -401,20 +377,78 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
}
}
// `C_Map.GetMapOverlays([areaID])` — see the module comment.
int __fastcall Script_GetMapOverlays(void *L) {
int wmaRow = -1;
if (Game::Lua::IsNumber(L, 1)) {
wmaRow = WorldMapAreaRowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
} else {
wmaRow = CurrentWorldMapAreaRow();
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
// null before the player is resident. Bit `AreaBit` set == that AreaTable
// area is explored. Same storage the engine's discovered-overlay rebuild
// (FUN_004a67a0) reads.
const uint8_t *ExplorationBits() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return nullptr;
return sub + Offsets::OFF_PLAYER_EXPLORED_BITS;
}
// True if the local player has discovered `overlayRec` — mirrors the
// per-areaID test in FUN_004a67a0's discovered-overlay rebuild: an overlay
// is explored if ANY of its (up to 4) AreaTable ids is explored. An area
// with gate (+0x28) < 0 needs no exploration; otherwise its AreaBit (+0x0C)
// is looked up in `bits`. `bits == null` (no player) → not explored.
bool IsOverlayExplored(const uint8_t *overlayRec, const uint8_t *bits) {
if (bits == nullptr)
return false;
for (int k = 0; k < 4; ++k) {
const int areaID = *reinterpret_cast<const int *>(
overlayRec + Offsets::OFF_WMO_AREA_ID + k * 4);
if (areaID <= 0)
continue;
const uint8_t *areaRec =
DBC::Record(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT, static_cast<uint32_t>(areaID));
if (areaRec == nullptr)
continue;
if (*reinterpret_cast<const int *>(areaRec + Offsets::OFF_AREATABLE_EXPLORE_GATE) < 0)
return true; // area needs no exploration → always counts
const int areaBit = *reinterpret_cast<const int *>(
areaRec + Offsets::OFF_AREATABLE_AREA_BIT);
if (areaBit < 0)
continue;
const int byteIdx = areaBit >> 3;
if (byteIdx >= 0x100)
return true; // out of bitfield range → engine treats as explored
if ((bits[byteIdx] & (1u << (areaBit & 7))) != 0)
return true;
}
return false;
}
// `C_Map.GetMapOverlays([areaID])` — see the module comment. Thin wrapper
// over the shared `PushZoneOverlays` with no exploration filter.
int __fastcall Script_GetMapOverlays(void *L) {
const int wmaRow = Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(static_cast<uint32_t>(
Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, wmaRow, Map::Overlays::Filter::All);
return 1;
}
} // namespace
// Shared overlay walk — see Overlays.h. Filters by exploration and builds
// the rich per-overlay table (texture + resolved tiles + hit rect + areaIDs
// + fileDataIDs). Uses the anonymous-namespace helpers above (same TU).
void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
Game::Lua::NewTable(L);
if (wmaRow <= 0)
return 1;
return;
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
@@ -425,6 +459,10 @@ int __fastcall Script_GetMapOverlays(void *L) {
Offsets::OFF_WMA_NAME)
: nullptr;
// Exploration bitfield only needed for the filtered variants.
const uint8_t *bits =
(filter == Filter::All) ? nullptr : ExplorationBits();
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
int outIdx = 0;
@@ -436,35 +474,56 @@ int __fastcall Script_GetMapOverlays(void *L) {
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
wmaRow)
continue;
if (filter != Filter::All) {
const bool explored = IsOverlayExplored(rec, bits);
if (filter == Filter::Explored && !explored)
continue;
if (filter == Filter::Unexplored && explored)
continue;
}
const char *texName = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_WMO_TEXTURE_NAME);
if (texName == nullptr || texName[0] == '\0')
continue; // hit-rect-only rows have no drawable texture
// Full path, mirroring the engine's own GetMapOverlayInfo format
// (unresolvable map dir falls back to the "World" folder).
char basePath[0x104];
if (mapDir != nullptr && mapDir[0] != '\0')
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
else
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\World\\%s", texName);
const bool hasTexture = (texName != nullptr && texName[0] != '\0');
const auto fieldInt = [rec](uintptr_t off) {
return *reinterpret_cast<const int32_t *>(rec + off);
};
const int dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
const int dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
const int offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
const ResolvedOverlay *r = CachedResolve(id, basePath, dbcW, dbcH);
// Texture placement + tile grid only exist for rows that carry art.
// A hit-rect-only row (areaID + hit rect, no texture — a clickable
// subzone region with no distinct BLP) still gets a result row: empty
// textureName/path, zero size, and an empty `tiles` table (r == null →
// PushTiles emits `{}`), so geometry consumers see it while art-draw
// loops (`ipairs(ov.tiles)`) iterate zero times. Stock 1.12 and Octo
// both happen to give every overlay a texture, but the DBC schema
// allows texture-less rows, so we don't assume.
char basePath[0x104];
int dbcW = 0, dbcH = 0;
const ResolvedOverlay *r = nullptr;
if (hasTexture) {
// Full path, mirroring the engine's own GetMapOverlayInfo format
// (unresolvable map dir falls back to the "World" folder).
if (mapDir != nullptr && mapDir[0] != '\0')
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
else
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\World\\%s", texName);
dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
r = CachedResolve(id, basePath, dbcW, dbcH);
} else {
basePath[0] = '\0';
}
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "textureName", texName);
Game::Lua::SetFieldString(L, "textureName", hasTexture ? texName : "");
Game::Lua::SetFieldString(L, "texturePath", basePath);
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
@@ -474,6 +533,41 @@ int __fastcall Script_GetMapOverlays(void *L) {
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
Game::Lua::SetFieldNumber(
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
// Hit rectangle (world-map canvas px, ~1002x668) — the tight clickable
// bounds of the landmass, present for every row (texture or not). The
// subzone-center source: (left+right)/2 / 1002, (top+bottom)/2 / 668.
Game::Lua::SetFieldNumber(
L, "hitRectTop", fieldInt(Offsets::OFF_WMO_HITRECT_TOP));
Game::Lua::SetFieldNumber(
L, "hitRectLeft", fieldInt(Offsets::OFF_WMO_HITRECT_LEFT));
Game::Lua::SetFieldNumber(
L, "hitRectBottom", fieldInt(Offsets::OFF_WMO_HITRECT_BOTTOM));
Game::Lua::SetFieldNumber(
L, "hitRectRight", fieldInt(Offsets::OFF_WMO_HITRECT_RIGHT));
// AreaTable ids this overlay reveals (WorldMapOverlay.dbc areaID[4]).
// `areaID` is the primary (first nonzero — every overlay has one);
// `areaIDs` is the full nonzero list (52 of 707 overlays span 2-3
// subzones, e.g. a lake straddling two areas). Same table shape as
// `tiles` — build the array, then assign the scalar.
int firstArea = 0, aCount = 0;
Game::Lua::PushString(L, "areaIDs");
Game::Lua::NewTable(L);
for (int k = 0; k < 4; ++k) {
const int aid = fieldInt(Offsets::OFF_WMO_AREA_ID + k * 4);
if (aid == 0)
continue;
if (firstArea == 0)
firstArea = aid;
aCount += 1;
Game::Lua::PushNumber(L, static_cast<double>(aCount));
Game::Lua::PushNumber(L, static_cast<double>(aid));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3); // overlay.areaIDs = { ... }
Game::Lua::SetFieldNumber(L, "areaID", firstArea);
if (r != nullptr) {
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
@@ -483,18 +577,34 @@ int __fastcall Script_GetMapOverlays(void *L) {
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
Game::Lua::SetTable(L, -3);
// fileDataIDs — ordered tile texture paths (retail's field name for
// the tile list). Vanilla has no numeric fileDataIDs, so these are the
// SetTexture-ready paths; redundant with tiles[].file, provided so
// retail-ported exploration addons reading info.fileDataIDs work,
// while tiles[] carries the per-tile draw geometry Octo needs.
Game::Lua::PushString(L, "fileDataIDs");
Game::Lua::NewTable(L);
if (r != nullptr) {
char file[0x120];
for (int i = 0; i < r->count; ++i) {
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushString(L, file);
Game::Lua::SetTable(L, -3);
}
}
Game::Lua::SetTable(L, -3); // overlay.fileDataIDs = { ... }
Game::Lua::SetTable(L, -3);
}
return 1;
}
namespace {
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
&Script_GetMapOverlays);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Overlays
+36
View File
@@ -0,0 +1,36 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Map::Overlays {
// Which overlays of a zone to include, by the local player's exploration.
enum class Filter {
All, // every WorldMapOverlay.dbc row for the zone (C_Map.GetMapOverlays)
Explored, // only overlays the player has discovered
Unexplored, // only overlays the player has NOT discovered
};
// Pushes an array of overlay tables for `wmaRow` (a WorldMapArea.dbc row),
// filtered by exploration, onto the Lua stack as a single value. Shared by
// `C_Map.GetMapOverlays` (Filter::All) and `C_MapExplorationInfo`'s
// explored / unexplored texture getters. The caller resolves the row (via
// `Map::Area`) and clears the stack first; this leaves exactly one table.
// The per-overlay table shape is documented in Overlays.cpp's header.
//
// Explored/Unexplored read the player's explored-areas bitfield; before the
// player is resident (character select) every overlay reads as unexplored.
void PushZoneOverlays(void *L, int wmaRow, Filter filter);
} // namespace Map::Overlays
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Map.GetMapWorldSize([areaID])` — the world size (in yards) of a zone,
// as `width, height`. This is the physical extent the map represents: the
// span of the `WorldMapArea.dbc` placement rect. Modern addons use it to
// convert map-relative percents to yard distances (e.g. tracking-range
// rings, "X yards away" readouts).
//
// With a numeric `areaID` (AreaTable id — the same identity
// `C_Map.GetBestMapForUnit` returns and `C_Map.GetMapOverlays` accepts),
// returns that zone's size. With no argument, returns the world map's
// currently viewed zone (same resolution `GetMapOverlays()` uses with no
// arg). Returns nothing (→ nil, nil) when the zone can't be resolved.
//
// WoW's world axes are +X north, +Y west; the WorldMapArea rect bounds
// them as locTop/locBottom (world X) and locLeft/locRight (world Y). Map
// width is the horizontal (east-west) extent and height the vertical
// (north-south):
// width = locLeft - locRight (world Y span)
// height = locTop - locBottom (world X span)
// The same spans are the denominators in the world→map percent transform
// (see `Map::AreaTriggers`), so a point's `mapX% * width / 100` is its
// offset in yards from the zone's east edge, and likewise for height.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
namespace Map::WorldSize {
namespace {
int __fastcall Script_GetMapWorldSize(void *L) {
const int row = Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
Game::Lua::SetTop(L, 0);
if (row <= 0)
return 0; // no values -> nil, nil
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(row));
if (rec == nullptr)
return 0;
const float left = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_LEFT);
const float right = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_RIGHT);
const float top = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_TOP);
const float bottom = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_BOTTOM);
Game::Lua::PushNumber(L, static_cast<double>(left) - right); // width (world Y span)
Game::Lua::PushNumber(L, static_cast<double>(top) - bottom); // height (world X span)
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapWorldSize",
&Script_GetMapWorldSize);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::WorldSize
+2 -11
View File
@@ -33,6 +33,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include "tick/WorldTick.h"
#include <cstdint>
@@ -42,7 +43,6 @@ namespace Merchant::Frame {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetItemRecord_t = const uint8_t *(__thiscall *)(
void *cache, uint32_t itemID, const uint64_t *guid,
void *callback, void *userData, bool requestIfMissing);
@@ -108,15 +108,6 @@ int ItemQuality(uint32_t itemID) {
// Player invMgr lives inline at player + 0x1D38. First uint32 is the
// max slot count; +0x04 is the GUID array pointer.
const uint8_t *PlayerInvMgr() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Walk a single bag, invoking `cb(cgItem)` for each non-empty slot.
// `cb` returns true to continue, false to stop iteration. Stomps
// the Lua stack — caller must own it (`ResolveBag` does the stomping).
@@ -161,7 +152,7 @@ int __fastcall Script_GetBuybackItemID(void *L) {
if (invSlot == 0)
return 0;
auto *invMgr = PlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr == nullptr)
return 0;
// First u32 of the invMgr struct is the max slot count.
+2 -9
View File
@@ -55,6 +55,7 @@
#include "Offsets.h"
#include "item/Arg.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -65,7 +66,6 @@ namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
@@ -83,13 +83,6 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// The local player's CGUnit/CGPlayer object (holds the descriptor at
// +OFF_UNIT_DESCRIPTOR and the CGPlayer sub-struct at +OFF_CGPLAYER_INFO).
const uint8_t *ResolvePlayer() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve("player"));
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
@@ -246,7 +239,7 @@ bool ComputeCanUse(int itemID) {
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false; // not cached → sync false, no load fired
auto *player = ResolvePlayer();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
+4
View File
@@ -52,6 +52,7 @@
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "event/Custom.h"
#include "player/StatSignal.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -112,6 +113,9 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
// Gear swap changes item +healing and Spirit/Armor — invalidate the
// GetSpellBonusHealing cache.
Player::StatSignal::Notify();
}
return 1;
}
+244
View File
@@ -0,0 +1,244 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The `PlayerLocation`-taking `C_PlayerInfo` accessors:
//
// C_PlayerInfo.GetName(playerLocation) -> name
// C_PlayerInfo.GetClass(playerLocation) -> className, classFilename, classID
// C_PlayerInfo.GetRace(playerLocation) -> raceID
// C_PlayerInfo.GetSex(playerLocation) -> sex (1 neutral / 2 male / 3 female)
// C_PlayerInfo.IsConnected([playerLocation]) -> isConnected
//
// A `PlayerLocation` (see Util/PlayerLocation.lua) is a plain table carrying
// either a unit token (`.unit`) or a GUID string (`.guid`). We resolve it to
// a 64-bit GUID — unit tokens through the engine's token→GUID table
// (`FUN_TOKEN_TO_GUID`, which resolves group members in other zones too, not
// just synced CGUnits), GUID strings by parsing — and then read everything
// straight from the engine's own data structures. No `Script_*` Lua wrappers
// are called; each value comes from the same engine internals the codebase
// already uses elsewhere:
//
// - GetName -> resolve the GUID to a CGObject and call the polymorphic
// name getter `FUN_OBJECT_GET_NAME` (same path as
// `UnitNameFromGUID`).
// - GetRace / -> UNIT_FIELD_BYTES_0: the login-time mirror globals when the
// GetClass / location is the local player (valid before the descriptor
// GetSex spawns, matching `Script_UnitClass`'s own player path),
// otherwise the resolved unit's descriptor bytes. GetClass
// maps classID -> className/classFile via `ChrClasses.dbc`
// like `C_CreatureInfo.GetClassInfo`. GetSex reports the
// API's 2/3 (raw gender byte 0/1 + 2), matching
// `GetPlayerInfoByGUID`.
// - IsConnected -> a resolvable CGObject means connected; otherwise the
// group-member roster's online bit (then slot presence),
// exactly what `Script_UnitIsConnected` checks.
//
// Only the `unit` and `guid` PlayerLocation kinds resolve on 1.12 — the chat
// line / who / battlefield-score / community / voice kinds have no
// client-side unit resolution. Unsupported or unresolvable locations return
// nil.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::LocationInfo {
namespace {
using TokenToGuid_t = uint64_t(__fastcall *)(const char *token);
using ResolveObject_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi, int line);
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
using GroupLookup_t = void *(__fastcall *)(const uint64_t *guid);
// Reads a string field `t[field]` at table stack index `locIdx` into `out`.
// Returns true only for a non-empty string. Stack-neutral.
bool ReadStringField(void *L, int locIdx, const char *field, char *out, int size) {
Game::Lua::PushString(L, field);
Game::Lua::GetTable(L, locIdx); // pops key, pushes value
bool ok = false;
if (Game::Lua::IsString(L, -1)) {
const char *s = Game::Lua::ToString(L, -1);
if (s != nullptr && s[0] != '\0') {
int i = 0;
for (; s[i] != '\0' && i < size - 1; ++i)
out[i] = s[i];
out[i] = '\0';
ok = true;
}
}
Game::Lua::SetTop(L, -2); // pop value (or nil)
return ok;
}
// Resolves the PlayerLocation table at `locIdx` to a 64-bit GUID. Unit tokens
// go through the engine token→GUID table (handles out-of-zone group members);
// GUID strings are parsed. Returns false for unsupported kinds or a token
// that maps to nothing.
bool LocationGuid(void *L, int locIdx, uint64_t *out) {
char token[64];
if (ReadStringField(L, locIdx, "unit", token, sizeof(token))) {
auto toGuid = reinterpret_cast<TokenToGuid_t>(Offsets::FUN_TOKEN_TO_GUID);
*out = toGuid(token);
return *out != 0;
}
char guidStr[40];
if (ReadStringField(L, locIdx, "guid", guidStr, sizeof(guidStr)))
return Guid::Parse(guidStr, out) && *out != 0;
return false;
}
// Resolves a GUID to its currently-synced CGUnit, or null if not resident.
const uint8_t *ResolveUnit(uint64_t guid) {
auto resolve = reinterpret_cast<ResolveObject_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "ClassicAPI", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0));
}
// Reads a UNIT_FIELD_BYTES_0 byte: the login-time mirror global when `guid` is
// the local player (works before the descriptor spawns), otherwise the
// resolved unit's descriptor byte. Returns -1 when the unit isn't resident.
int UnitByte(uint64_t guid, uintptr_t playerMirror, uint32_t descByteOffset) {
if (guid == Unit::Identity::PlayerGuid())
return *reinterpret_cast<const uint8_t *>(playerMirror);
const uint8_t *unit = ResolveUnit(guid);
if (unit == nullptr)
return -1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (desc == nullptr)
return -1;
return *reinterpret_cast<const uint8_t *>(desc + descByteOffset);
}
int __fastcall Script_GetName(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const uint8_t *unit = ResolveUnit(guid);
if (unit == nullptr)
return 0;
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(const_cast<uint8_t *>(unit), nullptr);
if (name == nullptr || name[0] == '\0')
return 0;
Game::Lua::PushString(L, name);
return 1;
}
int __fastcall Script_GetRace(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int raceID = UnitByte(guid, Offsets::VAR_PLAYER_RACE_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (raceID <= 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(raceID));
return 1;
}
int __fastcall Script_GetClass(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int classID = UnitByte(guid, Offsets::VAR_PLAYER_CLASS_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (classID <= 0)
return 0;
// className / classFile from ChrClasses.dbc — same reads as
// C_CreatureInfo.GetClassInfo(classID).
const char *classFile = DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME);
if (classFile == nullptr)
return 0; // classID with no ChrClasses row
const char *className = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
Game::Lua::PushString(L, className); // NULL → nil via lua_pushstring
Game::Lua::PushString(L, classFile);
Game::Lua::PushNumber(L, static_cast<double>(classID));
return 3;
}
int __fastcall Script_GetSex(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int gender = UnitByte(guid, Offsets::VAR_PLAYER_SEX_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_SEX_BYTE);
if (gender < 0)
return 0;
// UNIT_FIELD_BYTES_0 stores 0 = male / 1 = female; the API (and UnitSex)
// reports 2 = male / 3 = female. Same +2 remap as GetPlayerInfoByGUID.
Game::Lua::PushNumber(L, static_cast<double>(gender + 2));
return 1;
}
int __fastcall Script_IsConnected(void *L) {
// `playerLocation` is optional — with no table arg it defaults to the
// local player (always connected while playing).
uint64_t guid = 0;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE) {
if (!LocationGuid(L, 1, &guid))
return 0;
} else {
guid = Unit::Identity::PlayerGuid();
}
if (guid == 0)
return 0;
bool connected = ResolveUnit(guid) != nullptr; // a synced object is online
if (!connected) {
// Not resident — consult the group-member roster, mirroring
// Script_UnitIsConnected: the stats block's online bit, else slot
// presence (a member with no stats block yet still counts as present).
auto stats = reinterpret_cast<GroupLookup_t>(
Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP)(&guid);
if (stats != nullptr) {
connected = (*reinterpret_cast<const uint8_t *>(
static_cast<const uint8_t *>(stats) +
Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS) &
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
} else {
connected = reinterpret_cast<GroupLookup_t>(
Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP)(&guid) != nullptr;
}
}
Game::Lua::PushBool(L, connected);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetName", &Script_GetName);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetClass", &Script_GetClass);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetRace", &Script_GetRace);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetSex", &Script_GetSex);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "IsConnected", &Script_IsConnected);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Player::LocationInfo
+28
View File
@@ -0,0 +1,28 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "player/StatSignal.h"
namespace Player::StatSignal {
namespace {
// Starts at 1 so a consumer whose cached epoch is default-0 always computes on
// its first query.
uint32_t g_epoch = 1;
} // namespace
void Notify() { ++g_epoch; }
uint32_t Epoch() { return g_epoch; }
} // namespace Player::StatSignal
+38
View File
@@ -0,0 +1,38 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// A pull-based "the local player's stat inputs changed" signal. `Notify()` is
// called from the aura and equipment hooks whenever the player gains/loses an
// aura or swaps gear — the state that feeds derived stats like
// `GetSpellBonusHealing` (flat +healing, plus the Spirit/Armor talent
// conversions that scale with those descriptor fields).
//
// Unlike Tick::WorldTick (push / per-frame callback), this is a monotonic
// epoch that consumers POLL: a lazy cache recomputes only when `Epoch()`
// differs from the value it last computed at, so a per-frame query is O(1)
// between real changes and never does work on idle frames.
namespace Player::StatSignal {
// Bump the epoch — called from the aura / equipment hooks on a
// player-affecting change.
void Notify();
// Current epoch; increments on each `Notify()`. A consumer caches the value it
// last recomputed at and recomputes only when this differs.
uint32_t Epoch();
} // namespace Player::StatSignal
+85
View File
@@ -76,6 +76,87 @@ static int __fastcall Script_GetQuestIDForLogIndex(void *L) {
return 1;
}
// `C_QuestLog.GetLogIndexForQuestID(questID)` — the inverse of
// `GetQuestIDForLogIndex`: the 1-based quest-log index of `questID`, or nil
// if the quest isn't in the log. The index spans the full entry array
// (headers included), matching `GetQuestIDForLogIndex` /
// `GetQuestLogTitle`. Walks `VAR_QUEST_LOG_ENTRIES`, skipping header rows via
// the `+8` header-pointer gate, and returns the first matching entry's index.
static int __fastcall Script_GetLogIndexForQuestID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetLogIndexForQuestID(questID)");
return 0;
}
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (target <= 0)
return 0; // nil — no such quest
const int total = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
auto *base = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
for (int i = 0; i < total; ++i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
continue; // header row
const int questID = *reinterpret_cast<const int *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID == target) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
return 1;
}
}
return 0; // nil — quest not in the log
}
// `C_QuestLog.GetHeaderIndexForQuest(questID)` — the 1-based log index of the
// collapsible category header (zone / "Dungeon" / class sort, …) the quest
// sits under, or nil if the quest isn't in the log or has no header above it.
// The log is laid out header-then-its-quests, so we locate the quest's entry
// and walk backwards to the nearest preceding header row (`+8` non-NULL).
static int __fastcall Script_GetHeaderIndexForQuest(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetHeaderIndexForQuest(questID)");
return 0;
}
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (target <= 0)
return 0; // nil — no such quest
const int total = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
auto *base = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
// Locate the quest's entry.
int questIndex = -1;
for (int i = 0; i < total; ++i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
continue; // header row
if (*reinterpret_cast<const int *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID) == target) {
questIndex = i;
break;
}
}
if (questIndex < 0)
return 0; // quest not in the log
// Walk back to the nearest preceding header.
for (int i = questIndex - 1; i >= 0; --i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
return 1;
}
}
return 0; // nil — quest sits above any header (shouldn't normally happen)
}
// `C_QuestLog.IsOnQuest(questID)` — true iff `questID` is currently
// in the player's quest log (incomplete OR ready-to-turn-in; the log
// holds both). Walks `VAR_QUEST_LOG_ENTRIES` and matches against each
@@ -148,6 +229,10 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestIDForLogIndex",
&Script_GetQuestIDForLogIndex);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetLogIndexForQuestID",
&Script_GetLogIndexForQuestID);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetHeaderIndexForQuest",
&Script_GetHeaderIndexForQuest);
Game::Lua::RegisterTableFunction("C_QuestLog", "IsOnQuest",
&Script_IsOnQuest);
Game::Lua::RegisterTableFunction("C_QuestLog", "IsUnitOnQuest",
+293
View File
@@ -0,0 +1,293 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetSpellBonusDamage(school)` / `GetSpellBonusHealing()` — the local
// player's flat spell-damage (per school) and healing bonus.
//
// **Spell damage is a direct field read.** The value lives in the CGPlayer
// sub-struct at `[player + OFF_CGPLAYER_INFO]` (the +0xE68 blob), NOT the
// broadcast UpdateField descriptor at `+0x110` — the descriptor copy of
// this field is never sent to the client and reads zero (which sent an
// earlier version of this module down a "derive it from gear" path). The
// +0xE68 copy is the client's fully-computed value: `MOD_DAMAGE_DONE_POS[7]`
// at `+0xFD4`, `MOD_DAMAGE_DONE_NEG[7]` at `+0xFF0`, one int32 per school;
// net = POS − NEG. Because it's the finished value, gear + enchants + buffs
// + talents + set bonuses are all already baked in.
//
// This mirrors nampower's `Script_GetSpellPower` (same base + offsets),
// cross-verified in-game: a geared mage reads 613 generic across the magic
// schools with Fire at 647 (a +34 fire-specific piece on top of the 613).
//
// **Healing has no such field** — vanilla 1.12 never had `MOD_HEALING_DONE`
// (TBC addition; `PLAYER_FIELD_BYTES` follows `_PCT` directly), and nothing
// client-side computes a healing total (verified in-game: toggling a pure
// +44-healing item moved NO field in the +0xE68 struct). So
// `GetSpellBonusHealing` is DERIVED in two parts, both read from `Spell.dbc`:
//
// 1. Flat healing — Σ `SPELL_AURA_MOD_HEALING_DONE` (aura 135) off gear
// equip-effects, item enchants, random suffixes, and active buffs.
// 2. Talent/buff stat→healing — the server's own `SpellBaseHealingBonusDone`
// sums `SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT` (aura 175) ×
// total Spirit / 100 over the unit's aura-175 auras. We mirror it: read
// total Spirit from `UNIT_FIELD_STAT4` (descriptor +0x250), then sum
// aura-175 percents over PASSIVE known spells (talents — Spiritual
// Guidance, `Spell.dbc` ranks 14901/15028..15031 = 5/10/15/20/25%) plus
// active buffs. Generic (any aura-175 talent/buff, incl. Turtle customs)
// and robust — it reads the percent directly, no client-vs-server value
// delta.
//
// An earlier attempt derived (2) as (spell_damage_field − derived_gear_damage)
// — the part of the damage field gear doesn't explain. That was FRAGILE: the
// field reflects values AFTER SpellMods, but our gear sum used raw DBC values.
// Inner Fire (10951) is +60 in the DBC but +78 live (Improved Inner Fire, a
// +15%/rank SpellMod), so an 18-point gap leaked into healing when that pure
// +damage buff was gained. Reading aura 175 directly (above) sidesteps it.
//
// Residual: assumes talent ranks supersede (only the learned rank is known —
// otherwise summed) and misses set-bonus healing granted via a set spell.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/StatAccum.h"
#include "player/StatSignal.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Spell::BonusDamage {
namespace {
// ---------------------------------------------------------------------------
// Spell damage — direct read from the CGPlayer +0xE68 sub-struct.
// ---------------------------------------------------------------------------
const uint8_t *PlayerFields() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
}
// Net spell damage for a 0-based school (0=physical .. 6=arcane). POS is the
// full per-school value (generic "+spell damage" is already folded into every
// magic school by the server), NEG the signed-magnitude negative accumulator.
long SpellDamageForSchool(int school0) {
const uint8_t *pf = PlayerFields();
if (pf == nullptr)
return 0;
const int32_t pos = *reinterpret_cast<const int32_t *>(
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school0 * 4);
const int32_t neg = *reinterpret_cast<const int32_t *>(
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school0 * 4);
return static_cast<long>(pos) - static_cast<long>(neg);
}
// ---------------------------------------------------------------------------
// Healing — derived (no client field exists in vanilla).
//
// Item flat +healing goes through the shared Item::StatAccum engine (base
// record + on-equip spells + random suffix + applied enchants) — exactly the
// ITEM_MOD_SPELL_HEALING_DONE_SHORT (aura 135) value GetItemStats reports, so
// the item spell/suffix/enchant walk isn't re-implemented here. Buff / talent
// healing (flat + Spirit/Armor conversions) is decoded from Spell.dbc below.
// ---------------------------------------------------------------------------
// Flat +healing contributed by one equipped item, via Item::StatAccum.
long ItemFlatHealing(const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
Item::StatAccum::Accum acc[Item::StatAccum::kCount];
Item::StatAccum::Init(acc);
const int itemID = Item::ID::FromCGItem(cgItem);
if (itemID > 0)
Item::StatAccum::AccumulateRecord(
acc, Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID)), +1);
auto *desc = *reinterpret_cast<const uint8_t *const *>(
cgItem + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc != nullptr) {
// Random suffix ("… of Restoration") + applied permanent (slot 0) and
// temporary (slot 1) enchants (Healing Power, Mana Oil, …).
Item::StatAccum::ApplyRandomSuffix(
acc, *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY), +1);
for (int slot = 0; slot <= 1; ++slot)
Item::StatAccum::ApplyEnchant(
acc, *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_DESCRIPTOR_ENCHANTMENT_ID +
slot * Offsets::DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE), +1);
}
return Item::StatAccum::Value(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT");
}
// --- Buff / talent healing auras (flat + Spirit/Armor conversions) ---
// One decoder for every healing aura a buff or passive talent can carry,
// mirroring the server's SpellBaseHealingBonusDone:
// 135 MOD_HEALING_DONE → flat += amount
// 175 MOD_SPELL_HEALING_OF_STAT_PERCENT → += Spirit × amount / 100 (Spiritual Guidance)
// 199 MOD_SPELL_HEALING_OF_ARMOR_PERCENT → += Armor × amount / 100 (Turtle Ironclad)
// Amounts/percents are read straight from Spell.dbc — generic (any talent/buff
// using these auras, incl. Turtle customs) and robust (no client-vs-server
// value delta). `requirePassive` gates the bitmap walk to always-on talents,
// so a *known but not-currently-cast* buff (Divine Spirit) isn't counted as
// on; active buffs pass `requirePassive == false`.
constexpr int kAuraModHealingDone = 135;
constexpr int kAuraModHealingOfStatPercent = 175; // × Spirit
constexpr int kAuraModHealingOfArmorPercent = 199; // × Armor (Turtle Ironclad)
void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
int32_t armor, bool requirePassive) {
if (spellID <= 0)
return;
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
if (requirePassive &&
!(*reinterpret_cast<const uint32_t *>(sp + Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_PASSIVE))
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long amount = static_cast<long>(base[i]) + static_cast<long>(dice[i]);
if (aura[i] == kAuraModHealingDone)
total += amount;
else if (aura[i] == kAuraModHealingOfStatPercent)
total += static_cast<long>(spirit) * amount / 100;
else if (aura[i] == kAuraModHealingOfArmorPercent)
total += static_cast<long>(armor) * amount / 100;
}
}
long ComputeHealing() {
long total = 0;
// Items — flat +healing via the shared StatAccum engine.
for (int slot = 1; slot <= 19; ++slot)
total += ItemFlatHealing(Item::Location::ResolveEquipmentSlot(slot));
// Player Spirit / Armor for the aura-175 / aura-199 conversions.
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
const int32_t spirit = desc ? *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_UNIT_FIELD_STAT_SPIRIT) : 0;
const int32_t armor = desc ? *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_UNIT_FIELD_RESISTANCE_ARMOR) : 0;
// Active buffs — flat healing + Spirit/Armor conversions, one pass.
auto *table = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_TABLE));
for (int i = 0; i < Offsets::PLAYER_BUFF_TABLE_COUNT; ++i) {
const uint8_t *entry = table + i * Offsets::PLAYER_BUFF_ENTRY_STRIDE;
if (*reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE) < 0)
continue;
AddSpellHealingAuras(total, *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_PLAYER_BUFF_SPELL_ID),
spirit, armor, /*requirePassive=*/false);
}
// Passive talents — Spirit/Armor conversions (Spiritual Guidance, Ironclad)
// and any passive flat healing, via the known-spell bitmap (bounded by
// spellCount). The passive gate assumes ranks supersede (only the learned
// rank is known), so no per-rank dedup is needed.
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (bitmap != nullptr && spellCount > 0) {
for (int id = 1; id <= spellCount; ++id)
if (bitmap[id >> 5] & (1u << (id & 31)))
AddSpellHealingAuras(total, id, spirit, armor, /*requirePassive=*/true);
}
return total;
}
// Event-driven cache for the derived healing sum. The walk (StatAccum over 19
// items + the buff table + the known-spell bitmap) is expensive, so we
// recompute it ONLY when the player's stat inputs change — Player::StatSignal
// is bumped from the aura and equipment hooks, so a per-frame
// GetSpellBonusHealing poll is O(1) between real gear/buff changes.
//
// Every input to the sum is now event-driven: the aura, equipment, and
// spell-learn/unlearn hooks all bump Player::StatSignal (buffs, gear, and
// talent respecs respectively). This coarse world-tick recompute (at most once
// per kFallbackTicks frames, ~4s) is a pure backstop against a missed signal —
// e.g. a player aura the object-pointer guard in aura/Source didn't match —
// bounding staleness rather than being the primary mechanism.
constexpr int kFallbackTicks = 256;
uint32_t g_cachedEpoch = 0; // 0 = never computed (epoch starts at 1)
int g_ticksSinceCompute = 0;
long g_cachedHealing = 0;
void OnTick() { ++g_ticksSinceCompute; }
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
long CurrentHealing() {
const uint32_t epoch = Player::StatSignal::Epoch();
if (epoch != g_cachedEpoch || g_ticksSinceCompute >= kFallbackTicks) {
g_cachedHealing = ComputeHealing();
g_cachedEpoch = epoch;
g_ticksSinceCompute = 0;
}
return g_cachedHealing;
}
// ---------------------------------------------------------------------------
// Lua bindings.
// ---------------------------------------------------------------------------
// `GetSpellBonusDamage(school)` → number. `school` is 1-based (1=Physical
// through 7=Arcane). Raises the usage error for a missing / out-of-range
// school, matching 3.3.5's Script_GetSpellBonusDamage (0x0060E310).
int __fastcall Script_GetSpellBonusDamage(void *L) {
const int school =
Game::Lua::IsNumber(L, 1) ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
const unsigned idx = static_cast<unsigned>(school - 1);
if (idx >= static_cast<unsigned>(Offsets::SPELL_SCHOOL_COUNT)) {
Game::Lua::Error(L, "Usage: GetSpellBonusDamage(school)");
return 0;
}
Game::Lua::PushNumber(L, static_cast<double>(SpellDamageForSchool(static_cast<int>(idx))));
return 1;
}
// `GetSpellBonusHealing()` → number. The player's flat healing bonus (derived).
int __fastcall Script_GetSpellBonusHealing(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(CurrentHealing()));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSpellBonusDamage", &Script_GetSpellBonusDamage);
Game::Lua::RegisterGlobalFunction("GetSpellBonusHealing", &Script_GetSpellBonusHealing);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Spell::BonusDamage
+2 -8
View File
@@ -29,6 +29,7 @@
#include "Offsets.h"
#include "aura/Data.h"
#include "dbc/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -37,15 +38,8 @@ namespace Spell::Cancel {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CancelAuraSend_t = void(__fastcall *)(int spellID);
const uint8_t *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn("player"));
}
// Pre-validates `spellID` against Spell.dbc and ships the cancel
// packet. The engine's sender unconditionally derefs `[record+0x1C]`
// after a NULL-on-OOR record lookup, so skipping the
@@ -71,7 +65,7 @@ int __fastcall Script_CancelSpellByName(void *L) {
const char *targetName = Game::Lua::ToString(L, 1);
if (targetName == nullptr || *targetName == '\0')
return 0;
const uint8_t *player = ResolvePlayer();
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
// Walk the player's buff range only (slots 0..31). Modern
+30
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "player/StatSignal.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
@@ -586,6 +587,35 @@ static int __fastcall Script_C_Spell_IsSpellHelpful(void *L) {
return 1;
}
// --- Known-spell-bitmap change signal ------------------------------------
// Learning/unlearning a spell rewrites the bitmap GetSpellBonusHealing scans
// for talent conversions (Spiritual Guidance / Ironclad). Co-hook the engine's
// learn + unlearn writers and bump Player::StatSignal, so a talent respec
// invalidates the healing cache immediately — the same event-driven path as
// aura/equipment changes, no polling. Both fire only at login and on
// learn/unlearn (never per-frame), so they're safe co-hook targets.
using LearnSpell_t = void(__fastcall *)(uint32_t spellID, int notify,
uint32_t replacedSpellID);
static LearnSpell_t g_origLearnSpell = nullptr;
static void __fastcall LearnSpell_h(uint32_t spellID, int notify,
uint32_t replacedSpellID) {
g_origLearnSpell(spellID, notify, replacedSpellID);
Player::StatSignal::Notify();
}
static const Game::HookAutoRegister _hookLearnSpell{
Offsets::FUN_LEARN_SPELL, reinterpret_cast<void *>(&LearnSpell_h),
reinterpret_cast<void **>(&g_origLearnSpell)};
using UnlearnSpell_t = void(__fastcall *)(uint32_t spellID, int param2);
static UnlearnSpell_t g_origUnlearnSpell = nullptr;
static void __fastcall UnlearnSpell_h(uint32_t spellID, int param2) {
g_origUnlearnSpell(spellID, param2);
Player::StatSignal::Notify();
}
static const Game::HookAutoRegister _hookUnlearnSpell{
Offsets::FUN_UNLEARN_SPELL, reinterpret_cast<void *>(&UnlearnSpell_h),
reinterpret_cast<void **>(&g_origUnlearnSpell)};
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSpellInfo", &Script_GetSpellInfo);
Game::Lua::RegisterGlobalFunction("GetSpellLink", &Script_GetSpellLink);
+101
View File
@@ -22,6 +22,7 @@ namespace Spell::SkillLine {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
// pattern Spell::Level::PlayerDescriptor uses. Returns (0, 0) when the
@@ -171,9 +172,109 @@ int __fastcall Script_GetSpellSkillLine(void *L) {
return 2;
}
// `C_SpellBook.GetSkillLineName(skillLineID) → name`
//
// Localized `SkillLine.dbc::Name` for a skill-line id — the id → name
// half of the bridge addons build by hand. Pure DBC read: returns the
// name for ANY valid skill line (Herbalism = 182, Mining = 186, Swords,
// Defense, …) whether or not the player has learned it. `nil` for
// non-numeric / non-positive input or an id with no SkillLine.dbc row.
//
// Pairs with `GetSkillLineRank` (the learned-rank lookup): name comes
// from the DBC, rank from the player's live skill list.
int __fastcall Script_GetSkillLineName(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(id > 0) ? DBC::LocalizedField(Offsets::VAR_SKILL_LINE_RECORDS,
Offsets::VAR_SKILL_LINE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_SKILL_LINE_NAME)
: nullptr;
if (name == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_SpellBook.GetSkillLineRank(skillLineID) → curRank, maxRank, modifier`
//
// The local player's rank in a skill line, keyed by `SkillLine.dbc` id.
// Vanilla's `GetSkillLineInfo(index)` only walks the skill window by
// position and returns skills by *name*, so an addon starting from a
// SkillLine.dbc id (a quest's `requiredSkill`, "Herbalism" = 182, …) has
// to map id → localized name and string-match every skill line to recover
// the rank. This reads the player's live skill list keyed by id directly.
//
// Returns the same values `GetSkillLineInfo` reports for the line:
// curRank — current skill value (base, no temp bonus)
// maxRank — the line's cap at the player's level
// modifier — temporary bonus (weapon oils, buffs; 0 if none)
//
// Returns `nil` (nothing) when the player hasn't learned the line
// (`FUN_SKILL_LINE_TO_SLOT` reports no slot), for bad input, or before
// the player object / skill sub-struct is resident (character select).
// Reads the same CGPlayer `+0xE68` skill table `Player::CanUseItem`'s
// RequiredSkill gate uses; ranks verified there (Tailoring 261/300, …).
int __fastcall Script_GetSkillLineRank(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int skillLineID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (skillLineID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
if (player == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(player, static_cast<uint32_t>(skillLineID));
if (slot < 0) { // player doesn't have the skill line
Game::Lua::PushNil(L);
return 1;
}
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
const uint32_t cur =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
const uint32_t max =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
const uint32_t modifier =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
Game::Lua::PushNumber(L, static_cast<double>(cur));
Game::Lua::PushNumber(L, static_cast<double>(max));
Game::Lua::PushNumber(L, static_cast<double>(modifier));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSpellSkillLine",
&Script_GetSpellSkillLine);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineName",
&Script_GetSkillLineName);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineRank",
&Script_GetSkillLineRank);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+394
View File
@@ -0,0 +1,394 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_TaxiMap.GetTaxiNodesForMap(mapID)` (static) and
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` (live) — backports of the retail
// taxi-map node enumerators, plus the `Enum.FlightPathFaction` /
// `Enum.FlightPathState` tables so retail code ports unchanged.
//
// - **GetTaxiNodesForMap** reads `TaxiNodes.dbc` directly: every flight point
// on a continent (or all continents with no arg), faction-tagged,
// regardless of discovery — the static DB a zone-map addon (pfQuest's
// `pfDB.meta.flight`) wants. No flight master needed. Non-flight rows are filtered out — the DBC also holds transports/
// boats/zeppelins (no flight mount) and non-selectable markers like
// Northshire Abbey (no connecting flight path); a real flight master has a
// mount AND is a TaxiPath endpoint.
// - **GetAllTaxiNodes** wraps the open flight master's live reachable-node
// list (the legacy NumTaxiNodes/TaxiNode* globals' backing state): the
// nodes you can actually fly to right now, with per-node reachability and
// the slot index `TakeTaxiNode` takes. Empty when no taxi map is open.
//
// Ship both: static/DBC for databases + overlays, live/session for a custom
// flight-map UI. See docs/API.md for the field-by-field contract and which
// fields are retail vs ClassicAPI extensions.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
#include <cstring>
namespace Taxi::Map {
namespace {
// Enum.FlightPathFaction (retail values).
enum { FACTION_NEUTRAL = 0, FACTION_HORDE = 1, FACTION_ALLIANCE = 2 };
// Enum.FlightPathState (retail values).
enum { STATE_CURRENT = 0, STATE_REACHABLE = 1, STATE_UNREACHABLE = 2 };
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
int IntField(const uint8_t *rec, int off) {
return *reinterpret_cast<const int *>(rec + off);
}
// --- name-based zone resolution -------------------------------------------
//
// A taxi node's name is "Location, Zone" (e.g. "Chillwind Camp, Western
// Plaguelands"), so the zone is authoritative in the data itself — which
// beats any geometric world->zone guess at genuine border interleaving that
// the WorldMapArea/overlay boxes can't separate (Chillwind sits by Strahnbrad,
// deep in Alterac's overlay box, but is a WPL node). We resolve the name
// suffix against AreaTable and fall back to the geometric resolver only when a
// node has no ", Zone" suffix or the zone name doesn't match.
char AsciiLower(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; }
bool IEqual(const char *a, const char *b) {
while (*a && *b) {
if (AsciiLower(*a) != AsciiLower(*b))
return false;
++a;
++b;
}
return *a == *b;
}
// True if `name` begins with `prefix` followed by a space — a word-prefix, so
// the abbreviated taxi suffixes ("Redridge", "Arathi") match the full
// AreaTable names ("Redridge Mountains", "Arathi Highlands").
bool IStartsWord(const char *name, const char *prefix) {
while (*prefix) {
if (!*name || AsciiLower(*name) != AsciiLower(*prefix))
return false;
++name;
++prefix;
}
return *name == ' ';
}
// Pointer into `name` at the zone part (after the last ", "), or null when the
// name has no ", " separator.
const char *ZoneSuffix(const char *name) {
const char *best = nullptr;
if (name != nullptr)
for (const char *p = name; p[0] != '\0'; ++p)
if (p[0] == ',' && p[1] == ' ')
best = p + 2;
return best;
}
// Copies the location part (before the FIRST ", ") of `name` into `out`,
// null-terminated. Returns `out`, or null when `name` has no ", " separator.
// This is the "Orgrimmar" in "Orgrimmar, Durotar" — a capital's own city map
// when it's a mapped area, distinct from its outdoor region.
const char *LocationPrefix(const char *name, char *out, int outSize) {
if (name == nullptr)
return nullptr;
for (int i = 0; name[i] != '\0'; ++i) {
if (name[i] == ',' && name[i + 1] == ' ') {
int n = (i < outSize - 1) ? i : outSize - 1;
for (int j = 0; j < n; ++j)
out[j] = name[j];
out[n] = '\0';
return out;
}
}
return nullptr;
}
// AreaTable areaID for a zone name: exact (case-insensitive) match preferred,
// else the first word-prefix match. 0 when nothing matches.
int ResolveZoneByName(const char *zone) {
if (zone == nullptr || *zone == '\0')
return 0;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
int prefixHit = 0;
for (int id = 1; id <= count; ++id) {
const char *nm = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_AREATABLE_NAMES);
if (nm == nullptr)
continue;
if (IEqual(nm, zone))
return id; // exact wins outright
if (prefixHit == 0 && IStartsWord(nm, zone))
prefixHit = id;
}
return prefixHit;
}
// Pushes { x = px, y = py } as a subtable field `key` of the table on top.
void PushPosition(void *L, const char *key, double px, double py) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "x", px);
Game::Lua::SetFieldNumber(L, "y", py);
Game::Lua::SetTable(L, -3);
}
// Walks TaxiPath.dbc into two per-node-id flags:
// endpoint[] — the node is either end of some flight path. A real flight
// master always is; the fake rows (Northshire Abbey, spurious
// duplicate hub rows, standalone markers like "Eastern
// Plaguelands"/"Naxxramas") connect to nothing.
// dest[] — the node is a path DESTINATION (in-degree > 0), i.e. you can
// fly TO it. In-degree 0 marks a departure-only node reachable
// only by other means: druid Teleport (Nighthaven, Moonglade),
// a boat dock (Stormwind Harbor), etc.
// ids >= `size` are ignored (the vanilla table maxes ~122, so the fixed buffer
// is ample). Returns false when the path table isn't loaded.
constexpr int kMaxTaxiNodeID = 2048;
bool BuildTaxiGraph(bool *endpoint, bool *dest, int size) {
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
if (count <= 0)
return false;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
Offsets::VAR_TAXIPATH_COUNT,
static_cast<uint32_t>(i));
if (rec == nullptr)
continue;
const int from = IntField(rec, Offsets::OFF_TAXIPATH_FROM);
const int to = IntField(rec, Offsets::OFF_TAXIPATH_TO);
if (from >= 0 && from < size)
endpoint[from] = true;
if (to >= 0 && to < size) {
endpoint[to] = true;
dest[to] = true;
}
}
return true;
}
// `C_TaxiMap.GetTaxiNodesForMap([mapID])` — see module comment. A numeric
// `mapID` filters to that continent; omitted / non-number returns every
// flight master on every continent.
int __fastcall Script_GetTaxiNodesForMap(void *L) {
const bool filterMap = Game::Lua::IsNumber(L, 1);
const int wantMap =
filterMap ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : -1;
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
// Real flight masters are TaxiPath endpoints; `dest` marks the ones a
// path leads to (in-degree > 0) for the `reachable` field.
bool endpoint[kMaxTaxiNodeID] = {};
bool dest[kMaxTaxiNodeID] = {};
BuildTaxiGraph(endpoint, dest, kMaxTaxiNodeID);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXINODES_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
const int mapID = IntField(rec, Offsets::OFF_TAXINODE_MAP_ID);
if (filterMap && mapID != wantMap)
continue;
// Two filters isolate real flight masters from the DBC's non-flight
// rows: (1) at least one flight mount — drops transports/boats/
// zeppelins/instance markers (both mounts 0); (2) the node is a
// TaxiPath endpoint — drops Northshire Abbey, spurious duplicate hub
// rows, and standalone markers that connect to no flight path.
const int horde = IntField(rec, Offsets::OFF_TAXINODE_HORDE_MOUNT);
const int alliance = IntField(rec, Offsets::OFF_TAXINODE_ALLIANCE_MOUNT);
if (horde == 0 && alliance == 0)
continue;
if (id >= kMaxTaxiNodeID || !endpoint[id])
continue;
const char *name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_TAXINODE_NAME);
// The one path-connected, mounted row that still isn't a flight
// master: Blizzard's internal "Generic, World target" zeppelin-route
// marker. It's the sole such placeholder across all continents.
if (name != nullptr && std::strncmp(name, "Generic", 7) == 0)
continue;
const int faction = (horde != 0 && alliance != 0) ? FACTION_NEUTRAL
: (alliance != 0) ? FACTION_ALLIANCE
: FACTION_HORDE;
const float x = FloatField(rec, Offsets::OFF_TAXINODE_X);
const float y = FloatField(rec, Offsets::OFF_TAXINODE_Y);
const float z = FloatField(rec, Offsets::OFF_TAXINODE_Z);
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "nodeID", id);
Game::Lua::SetFieldString(L, "name", name ? name : "");
Game::Lua::SetFieldNumber(L, "faction", faction);
// reachable: a flight path leads to this node (in-degree > 0). False
// marks a departure-only node reachable only by other means — druid
// Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor);
// hide those from a non-class-specific "where can I fly" map.
Game::Lua::SetFieldBool(L, "reachable", id < kMaxTaxiNodeID && dest[id]);
Game::Lua::SetFieldNumber(L, "mapID", mapID);
Game::Lua::SetFieldNumber(L, "x", x);
Game::Lua::SetFieldNumber(L, "y", y);
Game::Lua::SetFieldNumber(L, "z", z);
// position: 0-1 on the continent map (retail-accurate). Falls back to
// {0,0} when the continent has no usable WorldMapArea row (instance
// maps); zone-map addons use areaID + mapX/mapY below instead.
double px = 0.0, py = 0.0;
::Map::Area::ContinentPercent(mapID, x, y, &px, &py);
PushPosition(L, "position", px, py);
// Derived zone anchor (areaID + 0-100 zone-relative percent), from the
// node's own name — authoritative, and right at borders geometry can't
// separate. Two candidate zones from the name, each accepted only if
// its WorldMapArea rect actually contains the point:
// 1. the LOCATION ("Orgrimmar" in "Orgrimmar, Durotar") — a capital's
// own city map, so the flight master pins on the city, not the
// surrounding region;
// 2. else the ZONE suffix ("Western Plaguelands") — the outdoor zone
// for a normal node (whose location is an unmapped subzone).
// Falls back to the geometric landmass resolver when neither name part
// resolves to a containing zone (no ", Zone" suffix, unknown name).
int areaID = 0;
double mapX = 0.0, mapY = 0.0;
bool haveZone = false;
char locBuf[128];
const int locArea = ResolveZoneByName(LocationPrefix(name, locBuf, sizeof(locBuf)));
const int zoneArea = ResolveZoneByName(ZoneSuffix(name));
if (locArea > 0 && ::Map::Area::PercentInZone(locArea, x, y, &mapX, &mapY)) {
areaID = locArea; // capital / mapped location -> its own city map
haveZone = true;
} else if (zoneArea > 0 &&
::Map::Area::PercentInZone(zoneArea, x, y, &mapX, &mapY)) {
areaID = zoneArea; // outdoor zone from the name suffix
haveZone = true;
} else {
haveZone = ::Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY);
}
if (haveZone) {
Game::Lua::SetFieldNumber(L, "areaID", areaID);
Game::Lua::SetFieldNumber(L, "mapX", mapX);
Game::Lua::SetFieldNumber(L, "mapY", mapY);
}
Game::Lua::SetTable(L, -3);
}
return 1;
}
using TaxiNodeType_t = const char *(__fastcall *)(uint32_t slot0Based);
int StateFromType(const char *type) {
if (type == nullptr)
return STATE_UNREACHABLE;
if (std::strcmp(type, "CURRENT") == 0)
return STATE_CURRENT;
if (std::strcmp(type, "REACHABLE") == 0)
return STATE_REACHABLE;
return STATE_UNREACHABLE; // DISTANT / NONE / other
}
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` — see module comment. The uiMapID
// arg is accepted for retail signature parity but ignored (vanilla has a
// single active taxi map — the open flight master's).
int __fastcall Script_GetAllTaxiNodes(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
if (count <= 0)
return 1; // no taxi map open
auto typeFn = reinterpret_cast<TaxiNodeType_t>(Offsets::FUN_TAXI_NODE_TYPE);
const auto *arrayBase =
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *slotRec = arrayBase + slot * Offsets::TAXI_NODE_STRIDE;
const uint8_t *dbcRec = *reinterpret_cast<const uint8_t *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
const float x = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_X);
const float y = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_Y);
int nodeID = 0;
const char *name = nullptr;
if (dbcRec != nullptr) {
nodeID = IntField(dbcRec, 0); // TaxiNodes.dbc id @ +0
name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(nodeID),
Offsets::OFF_TAXINODE_NAME);
}
const int state = StateFromType(typeFn(static_cast<uint32_t>(slot)));
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "slotIndex", slot + 1); // TakeTaxiNode arg
Game::Lua::SetFieldString(L, "name", name ? name : "");
Game::Lua::SetFieldNumber(L, "nodeID", nodeID);
Game::Lua::SetFieldNumber(L, "state", state);
PushPosition(L, "position", x, y); // flight-map 0-1, matches retail
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
static const Game::Lua::EnumIntegerEntry kFaction[] = {
{"Neutral", FACTION_NEUTRAL},
{"Horde", FACTION_HORDE},
{"Alliance", FACTION_ALLIANCE},
};
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathFaction", kFaction, 3);
static const Game::Lua::EnumIntegerEntry kState[] = {
{"Current", STATE_CURRENT},
{"Reachable", STATE_REACHABLE},
{"Unreachable", STATE_UNREACHABLE},
};
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathState", kState, 3);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiNodesForMap",
&Script_GetTaxiNodesForMap);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
&Script_GetAllTaxiNodes);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Taxi::Map
+87
View File
@@ -0,0 +1,87 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitCreatureFamilyID(unit)` — the numeric CreatureFamily id (the
// `CreatureFamily.dbc` row: 1 = Wolf, 3 = Cat, 4 = Bear, …) for a unit,
// or `nil` when the unit has no family.
//
// The stock `UnitCreatureFamily(unit)` returns only the *localized name*
// ("Wolf", "Chat"), which is awkward to compare and locale-dependent. This
// extension exposes the raw id addons actually want for stable checks
// (tameable-pet logic, family-specific ability tables, etc.) — the same
// value that indexes `CreatureFamily.dbc`.
//
// This is exactly the id the engine's `Script_UnitCreatureFamily`
// (`0x0051A310`) reads before its DBC name lookup: its family helper
// `FUN_006055e0` returns `[unit + 0xB30] ? [row + 0x1C] : 0`, then the id is
// bounds-checked against `CreatureFamily.dbc` and used to push the localized
// name. We stop at the id. `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Players and non-beast NPCs — family id is 0 (`UnitCreatureFamily`
// returns nil for these too).
// - NPCs whose creature data hasn't synced yet (no +0xB30 row).
//
// Raises a Lua error on a missing / non-string `unit` argument — same
// semantics as `UnitCreatureFamily` / `UnitSubName`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::CreatureFamily {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitCreatureFamilyID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCreatureFamilyID(\"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; // unsynced NPC / player unit
const int familyID = *reinterpret_cast<const int *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_FAMILY);
if (familyID <= 0)
return 0; // no family (players, non-beast NPCs)
Game::Lua::PushNumber(L, static_cast<double>(familyID));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitCreatureFamilyID",
&Script_UnitCreatureFamilyID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::CreatureFamily
+81
View File
@@ -0,0 +1,81 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitCreatureTypeID(unit)` — the numeric CreatureType id (the
// `CreatureType.dbc` row: 1 = Beast, 3 = Demon, 7 = Humanoid, …) for a
// unit, or `nil` when it has none.
//
// The stock `UnitCreatureType(unit)` returns only the *localized name*
// ("Humanoid", "Humanoïde"), which is awkward to compare and locale-
// dependent. This extension exposes the raw id — the twin of
// `UnitCreatureFamilyID`.
//
// It's exactly the value the engine's `Script_UnitCreatureType`
// (`0x0051A280`) resolves before its DBC name lookup, so we just call that
// same inner helper (`FUN_UNIT_CREATURE_TYPE`). The helper handles all three
// engine paths itself — a display-override, the creature-cache type, and the
// player-race type (players resolve to Humanoid via their race) — so a
// straight call matches `UnitCreatureType` for every unit kind. `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, etc.).
// - A unit with no resolvable type (helper returns 0).
//
// Raises a Lua error on a missing / non-string `unit` argument — same
// semantics as `UnitCreatureType` / `UnitCreatureFamilyID`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::CreatureType {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CreatureType_t = int(__fastcall *)(void *unit);
int __fastcall Script_UnitCreatureTypeID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCreatureTypeID(\"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);
void *unit = resolve(token);
if (unit == nullptr)
return 0;
auto creatureType = reinterpret_cast<CreatureType_t>(
Offsets::FUN_UNIT_CREATURE_TYPE);
const int typeID = creatureType(unit);
if (typeID <= 0)
return 0; // no resolvable type
Game::Lua::PushNumber(L, static_cast<double>(typeID));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitCreatureTypeID",
&Script_UnitCreatureTypeID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::CreatureType
+7
View File
@@ -119,6 +119,13 @@ const uint8_t *PlayerObject() {
/*line*/ 0));
}
const uint8_t *PlayerInventoryManager() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *PlayerDescriptor() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
+11
View File
@@ -44,6 +44,17 @@ const uint8_t *PlayerObject();
// re-deriving the player object + descriptor offset in each module.
const uint8_t *PlayerDescriptor();
// The local player's inventory manager (`PlayerObject() +
// OFF_PLAYER_INVENTORY_MANAGER`), or null. Layout: slot count at `+0x00`,
// flat GUID array at `+OFF_INVMGR_GUID_ARRAY`; it's the `this` that
// `GetItemBySlot` expects. This is the SAME pointer the throwing token
// resolver (`FUN_RESOLVE_UNIT_TOKEN("player")`) yields — both resolve the
// local player's GUID through the object manager (verified: the token
// resolver is `TokenToGUID → FUN_OBJECT_RESOLVE_BY_GUID`, differing only in
// the type-mask, which both the player object passes) — but non-throwing
// (null pre-world). Prefer this over re-deriving `player + 0x1D38` per module.
const uint8_t *PlayerInventoryManager();
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).