Compare commits

...
116 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
Brues 8bfe0b008c item: EquipItemByName auto-slot uses Item::Cursor::PickupBagItem
Replace the auto-slot path's Script_PickupContainerItem stack-fake
(SetTop + PushNumber x2 + call) with the shared
Item::Cursor::PickupBagItem, which drives the engine's cursor primitive
directly -- the same one C_Item.PickupItem uses. The ClearCursor at the
top still runs first, so PickupBagItem's empty-cursor guard is satisfied;
bail if the pickup didn't take (locked item / busy cursor) rather than
auto-equipping nothing. Drops the now-unused ScriptFn_t typedef.

FUN_SCRIPT_PICKUP_CONTAINER_ITEM now has no callers -- the Script-pickup
stack-fake is gone from the codebase. Explicit-dstSlot path (atomic
FUN_INVENTORY_SWAP) is unchanged. Verified in-game (auto-slot equip by
name and by id).
2026-07-15 17:35:42 -05:00
Brues e9c2e7416a item: add C_Item.PickupItem, via direct engine cursor primitives
C_Item.PickupItem(itemInfo) picks the matching item up onto the cursor.
itemInfo is an itemID / "item:NNN" / link / name (same input family as
UseItemByName / EquipItemByName): resolved via Item::Arg::Resolve and
found by Item::Location::FindByArg across equipment (1..19, checked first)
and bags (0..4) using the shared MatchesArg predicate (itemID, or the
decorated name for a name arg). An equipped match is lifted off the
paperdoll; a bag match off the container. Verified in-game (bag + equipped,
by id and by name).

Drives the engine's cursor-pickup primitives DIRECTLY rather than the Lua
Script_Pickup* state-machine wrappers (no faking the Lua stack), via a new
shared Item::Cursor:
  - PickupEquipmentSlot -> FUN_004C7300 (self-contained; own guards)
  - PickupBagItem       -> FUN_00494B60, replicating the two guards the
    Script wrapper applies (empty cursor + unlocked item) and the
    post-pickup source lock. The empty-cursor check reuses a new shared
    Cursor::Info::HasItem() (the same cursor-GUID globals GetCursorInfo
    reads), rather than duplicating it.

Supporting consolidation in Item::Location:
  - ResolveBagSlotDetail exposes the container object + linear slot the
    cursor primitive needs; ResolveBagSlot now delegates to it.
  - FindByArg (equipment-then-bags, MatchesArg) reuses FindByArgInBags for
    the bag half; FindByItemID is now its itemID-only specialization.

C_EquipmentSet.UseEquipmentSet was considered but already uses the atomic
FUN_INVENTORY_SWAP (no cursor). EquipItemByName's auto-slot path still uses
the Script wrapper; migrating it to Item::Cursor::PickupBagItem is a
follow-up.
2026-07-15 17:27:36 -05:00
Brues 6641e5ac13 item: add C_Item.IsConsumableItem (+ global IsConsumableItem)
Returns true if an item is a consumable: item class Consumable (0), or
inventory type INVTYPE_AMMO (24) / INVTYPE_THROWN (25). Registered as both
the namespaced C_Item form and the bare global, like IsUsableItem.

Definition established by decoding 3.3.5's Script_IsConsumableItem
(0x005173E0) then verifying the surviving contract on a modern client:
3.3.5 walked the item's on-use spells and returned true for any with a
real effect (which would include on-use trinkets), but that heuristic did
NOT survive -- an on-use trinket with a numeric Use: effect (Talisman of
Ephemeral Power) returns false on modern. It's a plain class/ammo check.
Verified in-game on our client, matching modern exactly: potion / bandage
/ Faintly Glowing Skull (all class 0) and Rough Arrow (Ammo) -> true;
Hearthstone, Talisman, plain armor -> false.

itemID / "item:NNN" / link input (names -> false, no vanilla name->ID
map); uncached -> false with no async load, same contract as
C_Item.IsEquippableItem. Documented in API.md (+ TOC) and the README
Item-row index.
2026-07-15 16:35:43 -05:00
Brues db1f00e0a3 item: add C_Item.IsItemInRange, via a shared Spell::Range core
C_Item.IsItemInRange(item, targetUnit) resolves the item to its on-use
spell (Item::Spell::OnUseSpellIDForItemID) and runs the same geometric
range test as C_Spell.IsSpellInRange: true / false in-or-out of range,
nil when the check doesn't apply (no on-use spell, a rangeless one, or an
unresolvable item/unit). Matches retail, which also returns nil for items
with no range restriction. item accepts itemID / "item:NNN" / link (names
aren't resolvable in vanilla -> nil). Range-only, ignoring LoS/faction,
like IsSpellInRange.

The range predicate SpellIDHasRange was about to become its fourth copy,
so extract the shared core into Spell::Range (SpellIDHasRange +
PlayerVsUnit, returning 1/0/-1). C_Spell.IsSpellInRange and
C_Item.IsItemInRange are now thin Lua wrappers over PlayerVsUnit, and
C_Spell.SpellHasRange / SpellHasRange call the shared SpellIDHasRange --
range logic single-sourced.
2026-07-15 15:59:41 -05:00
Brues 82062309a9 docs: item name/GetItem/by-name APIs return the decorated (suffixed) name
Follow-up to the random-suffix work (aa0279a, e8a9003, 2a218a0). API.md
still described the old base-name behavior in five places; update them:

- GameTooltip:GetItem() example: name is now the decorated name that
  agrees with the returned link ("Superior Cloak of the Eagle").
- C_Item.GetItemName: split the two forms -- location returns the
  decorated name (live instance, via the engine name builder), ByID
  returns the base name (an itemID has no instance/suffix).
- C_Item.IsEquippedItem / EquipItemByName / UseItemByName: name matching
  is against the decorated name (a suffixed item matches its full name,
  not the base), noting the one shared predicate behind all three.

Left the C_Item.GetItemData return-table `name` row as base m_name[0] on
purpose: that table is a static itemID-keyed record dump (same contract
as GetItemInfoInstant, which doesn't decorate either).
2026-07-15 15:36:54 -05:00
Brues 2a218a0a76 item: match decorated (suffixed) name via shared MatchesArg predicate
IsEquippedItem(name) and the bag find-by-name (FindByArgInBags, behind
UseItemByName/EquipItemByName/UseAtCursor) compared the caller's string
against the base ItemStats m_name[0], so a random-suffix item ("Foo of
the Owl") only matched when queried by its base name ("Foo"). The modern
client does the opposite -- verified in-game on both the modern client
and ours:

  IsEquippedItem("Iridium Chain")               -> false
  IsEquippedItem("Iridium Chain of the Owl")    -> true
  IsEquippedItem("iridium chain of the owl")    -> true  (case-insensitive)
  IsEquippedItem(12022)                         -> true  (itemID)

Match the decorated display name instead, via the engine name builder
(Item::Link::NameFromCGItem), which falls back to the base name for
unsuffixed items so plain items still match.

The match predicate was duplicated inline in both call sites -- which is
why this took two edits. Consolidate it into a single
Item::Location::MatchesArg(cgItem, resolvedArg): itemID-compare when
arg.itemID > 0, else case-insensitive decorated-name compare. Both
IsEquippedItem and FindByArgInBags now delegate to it, so the full chain
is single-sourced (Item::Arg parses input -> MatchesArg decides the match
-> NameFromCGItem / ID::FromCGItem supply the comparands). Also drop a
now-unused <string.h> from Equipment.cpp and fix the stale m_name[0] doc
on FindByArgInBags.
2026-07-15 15:31:55 -05:00
Brues e8a9003bf0 item: dress GameTooltip:GetItem() name with random suffix
GameTooltip:GetItem() resolved the CGItem and built the suffixed link via
Item::Link::FromCGItem, but returned the base ItemStats name beside it --
so name and link disagreed ("Iridium Chain" vs "[Iridium Chain of the
Owl]"). The old comment claimed the engine didn't expose the dressed name
as a separate string; it does, via the same builder the link uses.

Extract that builder into a shared Item::Link::NameFromCGItem helper
(next to FromCGItem, wrapping FUN_ITEM_BUILD_INSTANCE_NAME) and use it in
both GetItem and C_Item.GetItemName so the returned name matches the link
and modern behavior. Verified in-game: GetItem() now returns "Iridium
Chain of the Owl" alongside its matching link.

Left the base-name reads that are correct as-is: itemID-only paths (mail,
cursor-by-id, GetItemInfo, equipment-set missing items) have no instance,
and the by-name *matching* in IsEquippedItem / bag lookups compares
against a caller string (a separate semantics question).
2026-07-15 15:13:52 -05:00
Brues aa0279a600 item: decorate C_Item.GetItemName location form with random suffix
The location form of C_Item.GetItemName points at a real item instance,
so its name should carry random-suffix decoration ("... of the Owl") the
way modern WoW does -- but we were reading m_name[0] off the itemID-keyed
ItemStats_C cache, which only holds the base name.

Resolve the location to its CGItem and build the name via the engine's
own per-instance name builder (FUN_005D8BC0): it reads the suffix ID off
the descriptor (+0x98) and formats base + ItemRandomProperties localized
suffix through the engine's ITEM_SUFFIX_TEMPLATE. This is the same
builder FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the output
now matches GetItemLink's bracketed name and modern C_Item.GetItemName
exactly (verified in-game: "Iridium Chain of the Owl"). Falls back to the
base ItemStats name (and warms the cache) when the instance build yields
nothing.

GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
2026-07-15 15:06:04 -05:00
Brues a754d1ba22 Update TODO.md 2026-07-15 01:22:14 -05:00
Brues d55877649f item, unit: drop hand-rolled unit-token grammar mirrors
FUN_TOKEN_TO_GUID (0x00515970) is a pure parser: it returns 0 for any
input it doesn't recognize and never raises -- verified from the
disassembly (plain stack frame, no assert/SEH prologue; every
unknown-input path is xor eax,eax; ret). Both call sites had mirrored
the engine's token grammar by hand to pre-screen input, on the mistaken
belief this function throws (it was conflated with its sibling
FUN_RESOLVE_UNIT_TOKEN 0x00515940, which does throw).

- item/Use.cpp UseItemByName: delete LooksLikeToken / MatchBase /
  SkipTargetSuffixes / HasPrefix; just call FUN_TOKEN_TO_GUID and let it
  return 0 for non-tokens.

- unit/Flags.cpp UnitIsInMyGuild: delete LooksLikeKnownToken / HasPrefix
  / AllDigits. Resolve the input to a GUID via FUN_TOKEN_TO_GUID, then
  resolve GUID -> object via the non-throwing FUN_OBJECT_RESOLVE_BY_GUID
  (unit|player mask) for the fast guild-key compare, and GUID -> name via
  the NameCache for the roster fallback. This avoids the throwing
  resolver entirely, and -- because the token->GUID step goes through
  our TokenExtensions hook -- now supports focus / nameplateN, which the
  old grammar mirror silently treated as literal names. Matches what
  3.3.5's Script_UnitIsInMyGuild does (resolve to a GUID first).

No grammar to keep in sync with the engine anymore. Behavior otherwise
unchanged (self, guildmate/other-guild target, NPC, OOR raid member,
literal name, empty slot all verified case-by-case).
2026-07-14 16:29:14 -05:00
Brues f55779f87e item: fix UseItemByName ignoring the unit target for all but current target
UseItemByName resolved the unit token (arg 2) AFTER FindByArgInBags,
which stomps the Lua stack (clears it and pushes bag-slot numbers for
PackBagSlot). So ResolveUnitGuid read a leftover slot number instead of
the token: IsString failed, the GUID came back 0, and the engine fell
back to its default target. That default is the player's current target
for a targeted-use item, so "target" appeared to work while "focus",
"party1", "mouseover", etc. silently did nothing useful.

Resolve the target GUID before the bag walk (as the sibling UseAtUnit
already does). ResolveUnitGuid only reads slot 2 and resolves a C
string -- no stack stomp -- and leaves arg.name (slot 1) intact for
FindByArgInBags; the GUID is a plain value by the time the stack is
blown away.
2026-07-14 15:59:07 -05:00
Brues 91e88d768a unit: add UnitInLineOfSight(unit)
ClassicAPI extension (retail has no such global): true if the player
has clear LoS to unit, false if world geometry blocks it, nil when the
check can't apply (absent/unresolvable token, or non-unit object).

Traces a ray between the two units through the client's world-collision
core CWorld::Intersect (0x672170, verified in-binary: __fastcall(start,
end, unused, hitOut, tOut, flags), tail-calls the real intersect
FUN_0069BFF0). Flags 0x100111 = terrain + WMO (0x100171 also tests M2
doodads but crashes in some dungeons; matches vanilla server LoS which
ignores doodads). Endpoints are raised by each unit's collision-box
height (CMovement+0xB4) so foot-to-foot rays don't false-block on
terrain, with a two-pass level/look-up trace for height mismatches and
a 150yd guard against a long-segment crash.

Mirrors UnitXP_SP3's proven implementation (same VA, flags, guard, and
two-pass logic) but native, so it works with or without that addon --
we call the intersect rather than hooking it, and our flag isn't one
UnitXP_SP3's detour acts on. Gated on object type (unit/player) so it
never reads CMovement off a GameObject.

Adds FUN_CWORLD_INTERSECT, OFF_CMOVEMENT_COLLISION_BOX_HEIGHT,
OFF_CGOBJECT_TYPE_ID, OBJECT_TYPE_UNIT/PLAYER (reuses the existing
OFF_UNIT_MOVEMENT_INFO_PTR = 0x118), plus API.md and README entries.
2026-07-14 15:48:55 -05:00
Brues d358bd9515 spell: add C_Spell.IsSpellInRange(spellIdentifier, targetUnit)
Returns true/false for in/out of range, nil when the check does not
apply (rangeless spell, or unresolvable spell/unit). spellIdentifier
accepts a spellID, link, or spellbook name; targetUnit is a unit token.

Backed by the engine's own geometric range core FUN_006E47B0 -- the
same test the action-button range coloring uses (Script_IsActionInRange
to FUN_004E56F0 to FUN_006E4440 to FUN_006E47B0). It derives the
spell min/max range via FUN_006E3480 (which folds in the target's
bounding radius AND applies the range SpellMod op 5, so range-extending
talents/items like Hawk Eye and Nature's Reach count) then compares
center-to-center distance, so the boundary matches the client exactly,
melee and min-range spells included.

We call the pure core rather than FUN_006E4440: that wrapper also gates
on target type and folds range-not-applicable into the same truthy
return as in-range, so it cannot yield a clean true/false/nil. The core
is range-only, matching retail (ignores line of sight; does not reject
wrong-faction targets, documented). Positions are guarded via
Unit::Position::Read before the engine call, which dereferences the
GetPosition result without a null check.

Adds Offsets::FUN_SPELL_RANGE_CHECK (0x006E47B0) with derivation notes,
plus API.md and README entries.
2026-07-14 15:00:28 -05:00
Brues 4ba82e59e4 table: reset getn length counter in table.wipe
table.wipe nil'd every key via lua_next + rawset(nil) but left the
Lua 5.0 length counter untouched. In 5.0 a table's table.insert/getn
length lives out of band from its keys (a t.n field, else a weak
sizes[t] entry in the registry's LUA_SIZES table), so it survived the
clear. After a wipe, getn stayed at its old value and subsequent
table.insert appended past the wiped slots -- t[1] stayed nil. The
3.3.5 (Lua 5.1) twipe this was ported from has no getn legacy, which
is why the ported logic looked complete.

Fix: call luaL_setn(L, 1, 0) after the clear loop. Bound the engine's
luaL_setn (FUN_006f4ea0, verified against 5.0.2; located via
table.insert FUN_007fb6b0) as Game::Lua::SetN.

Repro before: table.insert(t,1); table.insert(t,2); table.wipe(t);
table.insert(t,3) -> getn 3, t[1] nil, t[3]=3.
After: getn 2, t[1]=3, t[2]=4. Verified in-game.
2026-07-14 00:37:19 -05:00
Brues b05c15ffa7 input: evict GetMouseButtonClicked so it reads nil outside a handler
Previously g_lastButton persisted as the last click forever. Modern
WoW returns the button only while a mouse handler is running and nil
otherwise (verified in 3.3.5: GetMouseButtonClicked pushes a
frame-manager field [mgr+0x1234] that is set before a mouse handler
dispatches and cleared after, so it is nil when null).

We cannot bracket the handler the same way: WoW dispatches the
frame-script mouse handlers deferred from the WH_GETMESSAGE pump, so
eviction is time-based via the existing per-frame WorldTick. The
button is still captured on each WM button message; EvictTick clears
it after a couple of quiet ticks with nothing held. A held button
keeps it (OnDragStart still reads it) and it survives the whole frame
its click lands in, so multiple addons hooking one OnClick all see it,
avoiding the multi-reader hole a consume-on-read latch would have.

Verified in-game: value matches the GLOBAL_MOUSE_DOWN button arg inside
a handler, and reads nil once clicks settle.
2026-07-13 23:41:33 -05:00
Brues 4daf6b9364 input: add GetMouseButtonClicked()
Returns the button name of the most recent mouse-button message
("LeftButton"/.../"Button5"), or nil before any click. Rides the
existing WH_GETMESSAGE stream in GlobalMouse that already backs
GLOBAL_MOUSE_DOWN/UP and IsMouseButtonDown, so no engine hook is
needed: g_lastButton is captured on every press/release, and the
Script fn pushes it (null tail-jumps to pushnil for the "no click
yet" case).

The hook runs at message-dequeue, synchronously before the engine
dispatches the click to the Lua handler, so the value reflects the
button of the handler currently running (and is readable from nested
helpers where arg1 isn't in scope). Not cleared between events -- no
"handler ended" signal in the 1.12 stream -- so outside a mouse
handler it returns the last-clicked button rather than nil; documented.
2026-07-13 19:02:51 -05:00
Brues 4c6c9deae6 namecache: share the on-disk cache across accounts per server
Move the persistent name cache from the per-account
WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt to a shared
WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt, so alts on
different accounts of the same server pool into one file instead of each
rebuilding their own.

Keyed by the realmlist address (unambiguous server identity — can't collide
across unrelated private servers, the same reason CredStore scopes on it)
plus the realm name. Since this path isn't under Account\, WoW doesn't
create it: EnsureParentDirs makes the tree before saving. On first load with
no shared file yet, a pre-existing per-account cache is migrated in (load
old file, mark dirty so the next save lands in the shared location); the old
file is left untouched.

The PersistentNameCacheEnabled / NameCacheScanEnabled toggles stay
per-account in WTF\Account\<acct>\ClassicAPI.txt — each account opts in
independently; only the cache contents are shared.
2026-07-13 12:52:52 -05:00
Brues 983cc3bdff unit: add UnitDistanceSquared
Backport retail's UnitDistanceSquared(unit) -> (distanceSquared,
checkedPosition): the squared world distance from the player to the unit,
plus a flag for whether both positions were readable. Squared because
almost all distance logic is a threshold compare or nearest-unit sort,
neither of which needs the sqrt — which is why retail never shipped a plain
UnitDistance.

Added to the existing Unit::Range module alongside UnitInRange (both now
share one PlayerToUnitDistSq compute off the CGObject::GetPosition vtable
virtual). Center-to-center, self-contained (reads the position in C — no
SuperWoW dependency, and leaves SuperWoW's own UnitPosition untouched).
Verified in-game to match SuperWoW UnitPosition math to float precision.

On a position miss returns (0, false); no self-quirk, so
UnitDistanceSquared("player") is a legitimate (0, true).
2026-07-13 00:23:58 -05:00
Brues 18ac033381 spell: add GetSpellLevelInfo + GetSpellRequiredTargetLevel
Expose the target-level gate for ranked beneficial auras — e.g. Divine
Spirit Rank 1 (14752) can only be applied to a target level >= 20.

- C_Spell.GetSpellLevelInfo(spellID) -> spellLevel, baseLevel, maxLevel
  (raw Spell.dbc level fields; nil for an unknown spell).
- GetSpellRequiredTargetLevel(spellID) (global, plus C_Spell mirror) ->
  the minimum castable target level (0 = no restriction; nil if unknown).

The computed helper mirrors the server's SpellMgr::SelectAuraRankForLevel:
returns spellLevel - 10 only when the spell is a ranked positive aura
(spellLevel > 10, not passive, has a Rank string, and has an APPLY_AURA
effect on a friendly/party/raid target or an APPLY_AREA_AURA_PARTY effect),
else 0. All fields read directly from the client's Spell.dbc.

The explicit MinTargetLevel / MaxTargetLevel fields the server also checks
are server-only columns (absent from the 1.12 client DBC, whose tail is
SpellFamilyName / SpellFamilyFlags / MaxAffectedTargets), so the spellLevel
rule is the only client-readable target-level mechanism.
2026-07-12 23:28:41 -05:00
Brues 81434f7014 aura: evict phantom cache entries on removal and descriptor reconcile
C_UnitAuras' descriptor-drop fallback could surface a "phantom" aura that
was actually gone — enumerated with a spellId but SetUnitAura rendering an
empty tooltip. Two cases, both fixed:

- Owner cancels a buff (e.g. a flask) while dead: the server keeps
  death-persistent auras through death without changing any field, so death
  itself fires no OnAuraRemoved. Any OnAuraRemoved we do receive for one is
  genuine (cancel/dispel/expiry), so always evict the cache entry — including
  death-persistent auras on dead units. Fixes the phantom for the caster and
  nearby observers (both receive the removal packet).

- A buff cancelled while its owner is out of an observer's range sends no
  packet, so the stale entry lingers. Reconcile on return: when a unit's
  descriptor holds >=1 raw aura it's in view and fully synced, so it's
  authoritative — evict any cached entry whose spell isn't in the array
  (Aura::Data::ReconcileCache -> Aura::Source::EvictAbsent, run at the top of
  the fallback path). Reads raw slots so a flag-hidden aura still counts as
  present; skips an empty array (can't tell out-of-range from buffless).

The only residual is a buff cancelled while the owner is still out of range,
which vanilla can't report until they return.
2026-07-12 17:10:27 -05:00
Brues 22605dad64 tooltip: format compare-item percent stats via PERCENTAGE_STRING
Block/dodge/parry chance and crit/hit are native vanilla percentages, so
their compare-tooltip delta read as a bare number ("-5 Block"). Attach the
percent via the locale-aware PERCENTAGE_STRING global so it renders
"-5% Block" without touching any _SHORT label (correct in every locale).

IsPercentStat marks the percent keys; FormatPercent substitutes the value
into PERCENTAGE_STRING's first conversion slot by hand (never feeds the
locale format to snprintf) with a plain "%" fallback. Flat stats (Defense,
Attack Power, Block Value) stay bare; dropped the now-redundant trailing
" %" from the English fallback labels.
2026-07-12 14:23:54 -05:00
Brues 7378e0b128 unit: add UnitIsMinion / UnitIsPet / UnitIsOtherPlayersPet
Player-minion classification for a unit, all owner-based:

- UnitIsMinion(unit): owned by a player (pet/guardian/totem). Primary
  check is the owner GUID, since summoned guardians ("X's Minion") carry
  an owner but not UNIT_FLAG_PLAYER_CONTROLLED; the flag is a fallback.
- UnitIsPet(unit): a player's controllable pet specifically -- owned by a
  player AND the engine's creature-family pet/minion classifier
  FUN_00605570 == 1, the same signal driving the UNITNAME_TITLE_PET vs
  _MINION tooltip label. The GUID can't be used (pets and guardians share
  the 0xF14 prefix) nor can PLAYER_CONTROLLED (a Minion can have it).
  ClassicAPI extension.
- UnitIsOtherPlayersPet(unit): owner is a player other than you; matches
  5.4.8's owner-based Script_UnitIsOtherPlayersPet.

Owner GUID read from CHARMEDBY (+0x10, verified via Script_UnitIsCharmed)
and CREATEDBY (+0x20, verified via the unit title builder FUN_0052FD30).
Verified in-game: Salvatory's Pet is a pet; Jimmyf's/Methl's Minion is a
minion but not a pet.
2026-07-12 03:06:11 -05:00
Brues 94b0545631 player: add C_PlayerInfo.CanUseItem (equip/use requirement gate)
The proficiency/level/class/skill/spell/rep gate that answers "can this
player equip or use this item" -- the tooltip's red-requirement logic,
distinct from IsEquippableItem (fits a slot) and IsUsableItem (on-use
castable). Mirrors the requirement checks FUN_0052B650 inlines:

  1. proficiency  - VAR_PROFICIENCY_TABLE[m_class] & (1<<m_subClass)
  2. RequiredLevel
  3. AllowableClass / AllowableRace
  4. RequiredSkill  - effective rank via FUN_SKILL_LINE_TO_SLOT
  5. RequiredSpell  - spell-knowledge bitmap (crafting specializations
                      like Armorsmith, proficiency spells)
  6. RequiredHonorRank / RequiredCityRank - PvP honor rank + medal mask
  7. RequiredReputation - current standing vs the reaction-min band

Verified in-game: mail boots false (proficiency), Riding mount false
without skill 150, Plans: Lionheart Helm false until Armorsmith learned.
Residual gap: a ranked RequiredSpell known only at a different rank.

Adds OFF_CGPLAYER_HONOR_RANK / OFF_CGPLAYER_PVP_MEDALS.
2026-07-12 01:54:05 -05:00
Brues 243ab37836 tooltip: localize item-compare stat labels via GlobalStrings keys
Compare-tooltip stat deltas now resolve their display label through
_G[key] (the WoW GlobalString), falling back to the built-in English
only when undefined. LabelFor tries _G[key], then _G[key.."_SHORT"],
then the English fallback: primary stats / resistances / CURRENTLY_EQUIPPED
resolve directly, while the rating/regen tokens GetItemStats keys on
(ITEM_MOD_CRIT_MELEE_RATING, ...) — whose own global is a "%s"
description — map to their compact, translatable _SHORT sibling for
display. StatAccum keys are left untouched (the GetItemStats contract,
confirmed against a TBC dump: ratings key on the non-_SHORT token).

The format guard now rejects only real printf conversions (%d/%s/%1$s),
letting a literal "%" unit through.

enUS.lua gains the ITEM_MOD_*_SHORT, ITEM_DELTA_DESCRIPTION and format
keys (native RESISTANCE*/CURRENTLY_EQUIPPED left to the client). The
non-US locales (deDE/esES/frFR/ruRU/zhCN/zhTW) are auto-filled from
Blizzard Classic (1.15) per-locale GlobalStrings; only the custom
BINDING_NAME_AOELOOT stays commented as an enUS fallback.

Fixes #8
2026-07-11 16:40:10 -05:00
Brues a12fff6704 Small refactor for clarity 2026-07-11 16:28:46 -05:00
Brues d747c699f5 Tighten up pfUI HookScript compat
Defer CreateFrame hook application until the updatenotify module loads, rather than applying it globally so it only affects the actionar module
2026-07-11 16:21:43 -05:00
Brues 88d76c04ad item: add IsUsableItem / C_Item.IsUsableItem (on-use usability)
Item analog of IsUsableSpell, returning (usable, noMana) for an item's
on-use ability -- not equippability. Gates on the item having an ON_USE
spell, its use requirements (RequiredLevel, AllowableClass/Race), then
the on-use spell's castability via the engine helper FUN_SPELL_IS_USABLE
(0x006E3D60), which sets noMana on power shortfall.

Shares the on-use spell-slot walk with C_Item.GetItemSpell: promoted
Item::Spell::FindOnUseSpellIDInRecord to the header so both use it
instead of re-deriving the trigger scan.
2026-07-11 14:55:20 -05:00
Brues 47d4b9c660 loot: stable rollID, winnerIdx, FULL_UPDATE dispatch, timestamp, Clear
Bring C_LootHistory closer to the MoP contract and make a UI keying on it
robust across the ring saturating.

- GetItem now returns rollID, itemLink, numPlayers, isDone, winnerIdx,
  timestamp (was itemLink-first, with the winner as a name string). rollID
  is a stable monotonic ID assigned at creation, so addons can key expanded
  / highlighted rows on it instead of the volatile ring index. winnerIdx is
  the winner 1-based player index for GetPlayerInfo, matching MoP; the winner
  is inserted into the roster if their own roll packet never arrived, so it
  always resolves. timestamp is a ClassicAPI extension (GetTime()-comparable
  seconds).
- LOOT_HISTORY_FULL_UPDATE is now actually dispatched (was reserved-only):
  on a new item appearing (roll open) and on ring eviction, fired before the
  granular ROLL_CHANGED / ROLL_COMPLETE.
- Add C_LootHistory.Clear() (ClassicAPI extension) to wipe history and fire
  FULL_UPDATE.
- Factor roll/winner roster insertion into a shared FindOrAddPlayer helper.

Docs updated for the new signature, Clear, and the FULL_UPDATE semantics.
2026-07-10 21:03:51 -05:00
Brues 8e9e30567c compat: hide HookScript on older pfUI forks' action bar buttons
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
2026-07-10 20:19:37 -05:00
Brues 8be78ad946 Fix trade skill link button positioning and add ATSW support
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.

Closes #7
2026-07-10 18:22:25 -05:00
Brues 0dea869ab1 tradeskill: hide detail scrollbar track groove when not scrolling
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
2026-07-10 17:11:26 -05:00
Brues b4dffb75c0 tradeskill: reagent tooltip fires over the whole element
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
2026-07-10 16:56:42 -05:00
Brues 9445fd6333 tradeskill: native-style detail scroll, reagent overflow, separator
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:

- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
  plain on Turtle) whose scroll child holds the product header + reagent
  grid, so the whole pane pixel-scrolls and clips partial reagent rows
  instead of the old fixed 2-row faux-scroll that clipped many-reagent
  recipes. Slider is managed explicitly (shown only when content
  overflows; re-asserted a frame after show so the engine's layout pass
  can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
  of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
  decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
2026-07-10 16:45:56 -05:00
Brues c8effdd6ec addons: fix crash in GetAddOnOptionalDependencies numeric path
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
2026-07-10 02:33:14 -05:00
Brues b3ac1c07f7 addons: add C_AddOns.GetAddOnOptionalDependencies
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
2026-07-10 02:22:28 -05:00
Brues cfbc4f4e9c loot: add C_LootHistory (group-loot roll history backport)
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.

Handlers, from the opcode dispatch FUN_005E6010:
  - START_ROLL  0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
  - ALL_PASSED  0x29E FUN_0061B640 (msg=EDX, RET 0)
  - ROLL_WON    0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
  - ROLL        0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.

Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.

Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
2026-07-10 01:59:52 -05:00
Brues bb9579c398 aura: flush source cache on map transition (stuck buffs after leaving BG)
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.

Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.

Reported via pfUI issue #20.
2026-07-09 23:55:12 -05:00
Brues 5642644833 add ClassicAPITradeSkillLinkFrame to UISpecialFrames 2026-07-09 23:13:42 -05:00
Brues 58e7e1a2c6 frame: add Frame:IsEventRegistered
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.

Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
2026-07-09 18:08:17 -05:00
Brues 5339c97b1e tradeskill: native single-column link viewer layout on stock 1.12.1
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.

Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
2026-07-09 17:26:10 -05:00
Brues 7406ac5db1 tradeskill: add link buttons to TradeSkillFrame and CraftFrame
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.

Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
2026-07-09 16:57:49 -05:00
Brues 1c263152e3 spell: fix negative cast/channel startTimeMs/endTimeMs on long-lived tick
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.

Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.

Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
2026-07-09 13:10:57 -05:00
Brues ab211962e5 unit: fix IsStealthed via UNIT_FIELD_BYTES_1 sneak flag
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.

_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.

Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
2026-07-09 11:53:58 -05:00
Brues 33a23c563c map: make GetMapOverlays overlay tiling HD-map-patch safe
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.

- Grid resolution detects the full-cell file size S (256*scale) and uses it
  as the partial-tile threshold instead of a literal 256, so HD edge tiles
  aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
  fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
  and needs no file-size guessing. Fixes overlays whose stock file was a 128
  cell (content <=128): the patch doubled them to 256, which the earlier
  S>=256 floor read as un-upscaled and halved their texcoord (the clipped
  dark patches).

Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
2026-07-09 11:18:27 -05:00
Brues 17304929b5 tradeskill: show craftable [N] on rows, refresh on BAG_UPDATE_DELAYED
Each recipe row appends " [N]" (min over reagents of floor(onHand/required),
via GetItemCount) like Blizzard's list. The frame registers
BAG_UPDATE_DELAYED and, while shown, re-runs Refresh (row counts) and
re-renders the selected recipe's detail pane (reagent have/need) so the
numbers track the reader's bags live.
2026-07-09 00:50:36 -05:00
Brues d88f02fc90 tradeskill: tighten link-viewer frame, add Exit button + craft bottom art
- SetHitRectInsets(2,82,2,73) so the mouse/drag region matches the visible
  parchment (decoded from the corner BLPs' alpha: opaque art spans frame
  x[2..686], y[2..439]) instead of the full 768x512 texture rect.
- Bottom quadrants use the CraftFrame's TW-TradeSkill-BotLeft2/BotRight2
  (the flat button-bar bottom) and add an Exit button positioned like
  CraftCancelButton.
- Quad helper uses the bare SetPoint(point) one-arg form (enabled by the
  Script_SetPoint co-hook in src/frame/Modern.cpp).
2026-07-09 00:44:39 -05:00
Brues 1c79742cc3 cleanup 2026-07-09 00:09:12 -05:00
Brues d696a16ac4 tradeskill: load link-viewer items via the Item mixin, drop the item-info poll
The detail pane and subclass dropdown now resolve crafted items/reagents
through Item:CreateFromItemID():ContinueOnItemLoad() — each uncached item
re-renders only its own pane/dropdown when its data arrives, guarded so a
load completing after a newer selection can't clobber it. Replaces the
global GET_ITEM_INFO_RECEIVED watcher that re-derived everything on a 0.3s
debounce whenever any item loaded anywhere.

The 'All' subclass view no longer reapplies the filter (and resets scroll)
as items trickle in; have/need counts still use GetItemCount and the
subclass name still uses the synchronous GetItemInfoInstant.
2026-07-08 23:57:53 -05:00
Brues 2c1962b207 tradeskill: add recipe detail pane to the trade-skill link viewer
Clicking a recipe row now shows a Blizzard-styled detail pane: the crafted
item (or enchant) with its icon on the Class-Trainer detail-header bar, the
spell description for enchants, and the reagent grid (icon + name plate +
have/need counts) matching the CraftFrame layout. The first recipe is
auto-selected on open; selecting a row tints the row highlight to the
recipe's difficulty colour and whitens its text.

Rows are now real ClassTrainerSkillButtonTemplate buttons (the template the
CraftFrame/TradeSkillFrame use) so name rendering and spacing match exactly.
Reagent + product data (createdItem, numMade, reagents) is folded into the
existing C_TradeSkillUI.GetTradeSkillListRecipes decoder rather than adding a
new function.

The real GetTradeSkillReagentInfo family is indexed by the player's own open
window, so it can't describe a linked recipe from someone else's profession;
the reagents are read straight from Spell.dbc instead.
2026-07-08 23:15:29 -05:00
Brues 23cdbe196d spell: CastAtCursor/CastAtUnit cast exact rank by spellID, accept spell name 2026-07-08 20:12:16 -05:00
Brues 8b6760cd77 tradeskill: crafting-window-style link viewer (difficulty colors, subclass filter, spell tooltips) 2026-07-08 14:24:25 -05:00
Brues 7c2317aca1 Suppress aura cache fallback when descriptor is authoritative
Stuck auras (pfUI issue #20 part 2): a no-duration debuff (Ghost/Spirit
in battlegrounds) stayed visible after the unit resurrected. It can only
come from the Aura::Source cache fallback — the descriptor path re-reads
the live aura array each call.

The aura is cached with expirationMs == 0 (infinite), so OnWorldTick
never expires it, and its real removal came via a bulk object drop
(death/leave-visibility, then resurrect) rather than a per-aura removal,
so OnAuraRemoved never fired to evict it. The infinite entry lingered and
the fallback resurrected it on the now-alive unit.

The fallback only exists for rogue stealth and party range fluctuation,
which both clear the unit's entire aura array at once. Gate both fallback
entry points on DescriptorHasVisibleAura: if the descriptor exposes any
live aura it's authoritative, so a cache-only aura is genuinely gone.
Stealth/range recovery is unaffected — those produce a wholesale-empty
aura array, so the fallback still fires there exactly as before.
2026-07-08 11:18:37 -05:00
Brues b764b14df1 Filter hidden auras in cache fallback path
Add visibility check to `FallbackEligible` to prevent hidden/internal auras (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE) from leaking through as phantom buffs. The descriptor path already drops these via `IsSlotPopulated`; the cache fallback must apply the same engine predicate to avoid surfacing invisible auras.
2026-07-08 10:55:33 -05:00
Brues fc1bd30bef tradeskill: add trade-skill list links (GetTradeSkillListLink/GetCraftListLink + viewer) 2026-07-08 00:18:07 -05:00
Brues fe29ec050c loc: fire LOSS_OF_CONTROL_UPDATE with unit payload ("player") 2026-07-07 17:46:51 -05:00
Brues 7fafb89d1d docs: move LOSS_OF_CONTROL events into the Events section 2026-07-07 17:40:53 -05:00
Brues bcefe3b87f loc: add C_LossOfControl (data query + ADDED/UPDATE events)
Synthesize the modern C_LossOfControl surface for the local player, which
vanilla lacks entirely (a Mists-era aggregation layer):

- SCHOOL_INTERRUPT: co-hook the SMSG_SPELL_COOLDOWN handler (FUN_006e9460,
  same __stdcall(opCode*, CDataStore*) shape/registrar as Spell::Cast's
  failure/channel hooks). Turtle's Player::ProhibitSpellSchool sends the
  lockout as ONE cooldown packet listing every spell of the interrupted
  school at one uniform duration -- a shape nothing else emits (verified
  against the server source) -- so a player-GUID batch of >=2 same-school
  spells with equal duration authoritatively yields the locked school
  (1<<Spell.dbc School) + duration. The interrupter isn't in the packet, so
  spellID=0 / displayText="Interrupted".

- CC types (STUN/FEAR/ROOT/CONFUSE/CHARM/POSSESS/SILENCE/PACIFY/
  PACIFYSILENCE/DISARM): scan the player's debuffs, classify each by its
  EffectApplyAuraName (values authoritative from the server's SPELL_AURA
  table). Timing is best-effort from the Aura::Source cache (nil when the
  applying cast wasn't observed -- the fields are nullable).

- LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE: a WorldTick poll-and-diff
  of the active set fires ADDED (with the 1-based eventIndex) per new effect
  and one coalesced UPDATE on any change. Chosen over event-driven aura hooks
  because effect EXPIRY has no engine packet (a tick is needed regardless),
  and the WorldTick tail keeps Lua handlers out of the mid-packet contexts
  the state updates in. Scan is bounded (16 debuff slots) and idle-cheap.

Placeholders with no vanilla equivalent: priority=0, displayType=2,
auraInstanceID=nil; iconTexture is the icon path (no FileDataIDs in vanilla).

Verified in-game: Polymorph -> CONFUSE, correct name/icon/duration, nil once
worn off; a counterspelled heal -> SCHOOL_INTERRUPT (lockoutSchool + 10s) +
SILENCE (4s), with two ADDED at cast and UPDATE as each expires.
2026-07-07 17:28:45 -05:00
Brues 9e03f69e9b xml: add C_XMLUtil.GetTemplateInfo() and DoesTemplateExist()
Both resolve by name through the engine's own template lookup
(FUN_006ee6f0) -- the same path inherits= uses -- so they're
case-insensitive and cover Blizzard FrameXML plus addon templates.

GetTemplateInfo returns { type, width, height, keyValues, inherits } or
nil if the template doesn't exist. type is the element tag; width/height
are the RAW values from a <Size> element declared directly on the
template (0 if none / inherited) -- read straight from the attributes
rather than via the engine's Size parser (FUN_006f1eb0), which applies
UI-scale conversion we don't want here. inherits is the inherits=
attribute or nil. keyValues is always empty (vanilla's XML schema has no
<KeyValues> element) and sourceLocation is omitted (a 10.2 feature;
vanilla records no per-node file/line) -- both parity-safe.

DoesTemplateExist returns a bool.

Verified in-game: DoesTemplateExist true/false as expected; GetTemplate-
Info nil for a missing name; StatFrameTemplate -> 104x13; FriendsFrame-
TabTemplate -> inherits "CharacterFrameTabButtonTemplate".
2026-07-06 22:26:15 -05:00
Brues 694a7415ab xml: add C_XMLUtil.GetTemplates()
Backport the modern C_XMLUtil.GetTemplates() -- enumerate every
registered virtual XML template as { name, type }.

The engine keeps virtual templates (the <Frame virtual="true"> etc. that
inherits= targets) in a Storm hash table. The XML parser (FUN_006ede10)
registers each via FUN_006ee500 and the inherits resolver reads the same
table via FUN_006ee6f0, so it's the authoritative template store. The
new module walks it: each node carries the template name (+0x14) and its
parsed definition node (+0x18), whose element tag string (+0x8) is the
frame type ("Frame" / "Button" / ...).

Covers both Blizzard FrameXML and addon templates; rebuilt on /reload.
Fonts are a separate registry and correctly excluded (matching retail).
Returns an empty table before the first template registers (mask ==
0xFFFFFFFF). Pure read, no hooks. Verified in-game: 212 templates,
entry 1 = { name="AtlasLootDefaultFrameButtonTemplate", type="Button" }.
2026-07-06 21:39:55 -05:00
Brues 23b102c60c spell/Cast: clear player channel on early stop; unify player-object access
UnitChannelInfo showed a stale "Ritual of Summoning" countdown after
helping a warlock summon: g_channel only cleared at its computed endMs
or when a timed cast superseded it, so a ritual that ends early (when the
summon fills) lingered, and a following instant cast -- which supersedes
neither cast timing nor the channel -- couldn't dislodge it.

OnWorldTick now polls the player's UNIT_FIELD_CHANNEL_SPELL (+0x228) and
clears g_channel when it drops to 0, gated by a confirm latch so the
field's ~1-tick start lag can't wipe a freshly stamped channel. Both
channel-stamp sites route through a new StampChannel helper that re-arms
the latch. Also fixes early-ended channels generally (interrupt / cancel
/ LoS break), which previously self-expired only at endMs.

Cleanup: factor the player-object/descriptor read into shared
Unit::Identity::PlayerObject() / PlayerDescriptor(), now used by
State.cpp, Cast.cpp's poll, and ComboDuration. PlayerObject() uses the
object-manager GUID resolve (FUN_OBJECT_RESOLVE_BY_GUID) -- the fastest
player-object path, non-throwing (null pre-world) -- replacing the
duplicated resolves (State.cpp's token resolver, ComboDuration's
hand-rolled GUID plumbing).
2026-07-06 19:52:01 -05:00
Brues e165aa6cd4 README: add Frame backports, RegisterComboDuration, GetMapOverlays rows 2026-07-06 00:56:29 -05:00
Brues 8ed32e6c8e C_Map.GetMapOverlays: zone overlays from WorldMapOverlay.dbc
Every overlay of a zone regardless of exploration, replacing the
hand-measured tables map-reveal addons ship (pfUI pfMapOverlayData).
The engine's GetNumMapOverlays/GetMapOverlayInfo only expose
DISCOVERED overlays -- the exploration gate is a server-fed ID list at
0x00B6E630 -- so unexplored areas were previously undrawable without
per-server data. Reads WorldMapOverlay.dbc directly instead, scoped by
the zone's WorldMapArea row (never by folder contents).

No argument = the world map's currently viewed zone (same
continent/zone resolution GetMapInfo performs, globals 0x0084506C /
0x00845070 / 0x00B6E668); numeric argument = AreaTable areaID, the
same identity C_Map.GetBestMapForUnit returns.

Tile grids are resolved in the DLL rather than left to consumers,
because Octo's shipped data breaks the canonical ceil(w/256) layout
three ways (mirroring the InkLab map generator's empirically-derived
disambiguation):
- sliver columns the DBC rect rounds away (Icepoint's Kaneq'nuun has a
  real 8px third column; DBC w=512 implies 2 -- naive layout shears
  the overlay into misplaced strips, the one visibly broken overlay);
- foreign tiles appended to an overlay's number sequence (Kaneqnuun
  ships 12 tiles, the true grid is 3x2 = 6);
- same-rect higher-resolution re-exports (Stonetalon's WindshearCrag:
  4 real tiles for a 1-tile rect, kept and scaled).
The true grid comes from the actual BLP header dimensions (width @
+0x0C, height @ +0x10, BLP1 and BLP2 alike) read through the engine's
own VFS reader, since only a row's last tile may be narrower than 256.
A sweep of all 707 overlays found 108 art/DBC mismatches; Kaneq'nuun
is the only shear-class one, matching the single visible artifact.
Resolution is cached per overlay per session.

Each overlay returns the DBC fields plus a ready-to-draw `tiles`
array (file / width / height / texCoordX / texCoordY / offsetX /
offsetY) so consumers need no grid math at all. WorldMapArea.dbc and
WorldMapOverlay.dbc schemas verified against fresh extractions and
the engine's own Script_GetMapOverlayInfo (0x004A8A00).
2026-07-06 00:54:57 -05:00
Brues 26da489773 re-enable HookScript 2026-07-05 23:40:49 -05:00
Brues 9c73430ddd temporary disable HookScript 2026-07-05 23:40:04 -05:00
Brues e712af11a5 Drop stale WOW_PROJECT_ID rationale from Frame backport docs 2026-07-05 18:19:34 -05:00
Brues 45291cb16b Modern Region/Frame method backports (Frame::Modern)
ClassicAPI advertises WOW_PROJECT_ID = WOW_PROJECT_CLASSIC, which
steers modern addon ports onto their Classic Era code paths -- where
they also assume Era's C-level frame behaviors. Supply the missing
ones (surfaced by AI_VoiceOver's Era path, but generic to every port):

- region:SetPoint("RIGHT") -- the one-arg form. Vanilla's parser
  accepts every other modern shape but raises the usage error when
  arg 3 is fully omitted (LUA_TNONE fails its type validation); a
  co-hook on Script_SetPoint (0x007A2540, the ONE implementation in
  the Region base table) normalizes it to (point, 0, 0) -- vanilla's
  parent-relative form, exactly the modern semantic.
- region:SetSize / region:GetSize -- registered on the REGION base
  method registry (ctx 0x00CF54B4), so they resolve on frames,
  buttons, textures, fontstrings -- including engine-created textures
  (GetNormalTexture():SetSize(...)).
- frame:SetShown -- Show/Hide are per-branch script functions with
  their own object typechecks, so it's registered separately on
  Frame / Texture / FontString with matching delegates.
- frame:SetResizeBounds -- modern rename of SetMinResize/SetMaxResize.
- frame:HookScript -- absent in vanilla entirely. Chains the old
  handler (invoked vanilla-style, reading the this/event/argN
  globals), then invokes the hook handler with modern positional args
  ((frame, event, arg1..arg9) for OnEvent, (frame, arg1..arg9)
  otherwise) via a C closure over (handler, old, isOnEvent).

All methods delegate to the engine's own Script_* implementations by
reshaping the Lua stack -- no re-derived setters, so UI-scale
conversion, object resolution, and type checking stay engine code.
Method-registry contexts beyond GameTooltip's (Region / Frame /
Texture / FontString) are now in Offsets.h per the
docs/BlizzardScriptAPI.md catalogue.

Verified in-game: GameTooltip:GetSize() returns real dimensions,
CreateFrame + SetSize(50,50) reads back 50, and AI_VoiceOver's Era
path runs through its entire frame construction (SetPoint one-arg,
SetSize, SetShown, SetResizeBounds, 14 HookScript sites) where it
previously errored on the first call.
2026-07-05 18:16:47 -05:00
Brues 9bfbb2e6b0 We have other ways to detect 1.12.1 and this is causing conflict 2026-07-05 18:16:13 -05:00
Brues d6bf05c12c Combo-point finisher durations for C_UnitAuras
Rupture at 4 combo points reported duration=6 (the SpellDuration.dbc
base) instead of the real 14s: finisher durations are computed
server-side at cast time and 1.12 never transmits them for debuffs the
player puts on other units. Verified with a whole-protocol sniff --
SMSG_UPDATE_AURA_DURATION (0x137) is self-scoped (slot+duration for the
player's own aura array), 0x2DD has no client handler and never
arrives, and nothing else carries the value. (pfUI issue #17.)

Mirror the server's computation instead (new Aura::ComboDuration):

- Snapshot combo points ([player+0xE68]+0x1029, the field behind
  Script_GetComboPoints) the instant CMSG_CAST_SPELL leaves, via a
  co-hook on the NetClient send funnel (FUN_NET_SEND 0x005379A0). The
  server consumes CP before SMSG_SPELL_GO returns, so reading at
  SpellGo time is too late; the send funnel also covers
  nampower-queued casts, which bypass any Spell_C_CastSpell co-hook
  via trampoline re-entry.
- Compute base + (max - base) * CP / 5 from the spell's own
  SpellDuration.dbc row ((v)mangos CalculateSpellDuration), then apply
  the player's duration SpellMods, matching server order. DBC-driven
  and spell-agnostic: finishers are exactly the rows with base != max
  (Rupture 6/16s, Kidney Shot 1/6s, r1 0/5s, Slice and Dice 6/21s;
  Expose Armor 30/30 self-disables). Consumed by Aura::Source's
  SpellGo hook; 0 falls through to the existing base path.
- Turtle's reworked Rip (8s + 2s per CP) has NO client data: all six
  ranks' DurationIndex points at SpellDuration row 87, which Turtle
  added server-side only -- the client table jumps 86 -> 105. Built-in
  values cover it, gated on the dangling-row condition itself (the
  only dangling cross-reference in the entire client Spell.dbc, per a
  full DurationIndex/CastingTimeIndex/RangeIndex sweep) rather than on
  realm detection: stock clients resolve Rip's index normally, and if
  Turtle ships the missing row the DBC takes over automatically.
- C_UnitAuras.RegisterComboDuration(spellID, baseSec, maxSec) as an
  escape hatch for other servers with the same class of client-data
  gap; a registered override beats DBC and built-ins.

Verified in-game: Rupture at 4 CP now reads duration=14.
2026-07-05 17:12:10 -05:00
Brues 07a4cbde43 Evict remote casts via CGUnit_C::ClearCastingSpell choke point
Turtle's core doesn't broadcast SMSG_SPELL_FAILURE or
SMSG_SPELL_FAILED_OTHER on interrupt (verified by logging both
handlers through live tests), so the SPELL_FAILED_OTHER co-hook never
fired and interrupted enemy casts (Kick / Earth Shock / Counterspell)
lingered as ghost bars. What actually stops the cast visual on Turtle
is SuperWoW calling CGUnit_C::ClearCastingSpell (0x0060D040) directly.

Co-hook that choke point instead: every unit-stopped-casting path
funnels through it (failure packets, SPELL_GO completion, movement,
death, animation events, local cancel), gated on the unit's live
current-cast field at +0xC8C, so eviction is independent of which
signal the server sends. Both failure-packet handlers stay co-hooked
as backstops for servers that do broadcast them. Verified in-game:
all three interrupts evict; non-interrupting damage doesn't
false-evict.

Also confirmed via a whole-protocol sniff on the NetClient message
dispatch (FUN_NET_MESSAGE_DISPATCH = 0x00537AA0, documented for
future use): SMSG_SPELL_DELAYED goes only to the affected caster --
observers receive no packet of any kind for pushback, so remote bars
can't stretch. Documented as an inherent 1.12 protocol limitation.
2026-07-05 01:57:18 -05:00
Brues 017dc5b6f1 Add UPDATE_INVENTORY_DURABILITY (TODO #89)
Fires (no payload) when any equipped item's durability changes -
combat damage, death, repairs - via a co-hook on the engine's
inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's
UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's
fields change (the item post-update handler FUN_005D9A90 routes every
item values-update through it) plus equip/enter-world paths, so it is
the engine's own durability-may-have-changed signal. Post-original we
diff a per-slot {itemGuid, durability} snapshot and fire once per
recompute with a real change - a death dinging all armor produces one
event, not nineteen.

The snapshot seeds silently on the first recompute of the process and
is deliberately NOT re-seeded per enter-world: death/release/graveyard
teleports re-run the setup, and a per-setup reset swallowed the
death's own durability loss (verified in-game; repairs fired, deaths
didn't until this was fixed).

Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM
field) descriptor observers register without error but are never
invoked - item values-updates demonstrably arrive and player-bank
observers fire in the same session; registration arithmetic fully
verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2).
Prime suspect is the registrar's internal wrapper lookup
(FUN_00464890, the observer registry, distinct from the object
manager) silently no-op'ing for item objects.

Also in Player::Equipment: resolve the player via the non-throwing
GUID resolver (FUN_00468460, what the engine's own bag observer uses)
instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the
token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing
where the player can be mid-create.
2026-07-05 00:23:01 -05:00
Brues ddcb6ba9f0 Add C_QuestLog.GetNumQuestObjectives(questID)
The quest's objective count — the length GetQuestDetails(questID)
.requirements would have — read straight from the cache block with no
Lua tables or strings materialized. nil when the quest isn't cached
(same contract as GetQuestDetails).

Exists because materializing full detail tables in a login-path loop
is expensive at warm cache: GetQuestDetails copies description /
objectives / completion text into Lua strings (~5 KB per quest).
TurtleGuide's guide-load OIDX validation did exactly that for every
tagged step — fine on the first (cold-cache) session, but once the
quest cache was warm from questcache.wdb every subsequent login
exhausted vanilla's fixed Lua memory pool and crashed the client in
lmemPool.cpp:53. With this accessor the same validation costs a few
dozen byte reads regardless of cache temperature.
2026-07-04 20:02:25 -05:00
Brues 17cf323a52 Add PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent) (TODO #88)
The earlier investigation tried to hook a firer of
UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites
trigger on equip because equipment descriptor writes are silent — the
engine registers descriptor-field observers (the __AUCMirrorHandler__
system: registrar 0x00467E70, dispatcher 0x00465570 with per-node
mirror + memcmp change detection) for the bag/backpack/bank/keyring
GUID ranges but never for equipment 0x4A8..0x538.

So register our own: co-hook the engine's inventory observer setup
(FUN_004F8CC0, once per enter-world) and, post-original, register one
observer per equipment GUID field through the engine's registrar —
same bank/size/GUID shape as the engine's bag observers, engine-
managed lifetime, no polling, no hot-function hook. The dispatcher
invokes us only on a real change.

Callback ABI verified from the dispatcher call site at 0x004655DC and
the engine callback FUN_004F8DB0's RET 0x10:
  __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
             oldValue*, userArg) -> ret 0x10

equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard);
hasCurrent (1/nil) is read from the player invMgr GUID array — the
same post-change truth the engine's bag observer reads.
2026-07-04 17:01:01 -05:00
Brues a0b7d03c28 C_GossipInfo: session gating + quest-greeting support
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.

- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
  cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
  fires). Stale text/options/quests are no longer visible, and
  selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
  questgiver GUID (0x00BE0810) must match the greeting-fill GUID
  (0x00C4D740) so leftovers can't leak when a quest session starts
  from a gossip-menu click. GetText returns the greeting text;
  Get{Available,Active}Quests / GetNum* read the greeting arrays
  (32 x 0x4C entries {questID, level, title}) with isComplete
  recovered from the packet handler's scratch status array
  (status 4 = turn-in-ready); Select{Available,Active}Quest route to
  the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.

GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
2026-07-04 14:27:06 -05:00
Brues 15cae8a474 Add QUEST_REMOVED event; refactor quest log hook
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
2026-07-04 12:04:27 -05:00
Brues 49865e9b9e Fix cast-then-channel spells showing the channel bar at cast start
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.

- Treat a channeled spell with castTime > 0 as its CAST phase in
  HandleSpellStart (player and remote paths); instant channels keep
  the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
  nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
  The server sends it to the caster the moment the channel begins —
  after MC's cast completes — with the server-authoritative duration.
  Stamp g_channel there, before the original fires
  SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
  channels it re-stamps what the SpellStart path just wrote.

Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
2026-07-03 21:36:42 -05:00
Brues 98d4504b6f CBOR encode: keep encoding through OutOfMemory so the resize tally is complete
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.

Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
2026-07-03 20:48:39 -05:00
Brues 597530d558 Evict interrupted casts via SMSG_SPELL_FAILED_OTHER (TODO #98)
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).

Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:

- evict the matching g_remoteCasts entry (guid + spellID), so remote
  cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
  server-stamped chained recast that the WorldTick VAR==0 poll can't
  clear (self-receipt is server-dependent; the endMs self-expiry
  backstop remains if it never fires).

Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
2026-07-03 20:24:14 -05:00
Brues 2165a8e986 Restore TTS with VanillaTTS coexistence
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.

The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.

Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
2026-07-03 19:00:17 -05:00
120 changed files with 16372 additions and 1066 deletions
+2 -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
@@ -35,5 +35,6 @@ Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
+29
View File
@@ -8,5 +8,34 @@ if C_AddOns.DoesAddOnExist('pfUI') then
unknownColor.colorStr = unknownColor:GenerateHexColor()
return unknownColor
end})
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
-- HookScript widget method; older pfUI forks branch on
-- `if button.HookScript` and take a modern code path they were never
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not strfind(website, 'brues') then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
if frameType == "Button" and strfind(name or "", "pfActionBar") then
frame.HookScript = false
end
return frame
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "updatenotify" then
CreateFrame = HookedCreateFrame
elseif moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
end
end)
end
-8
View File
@@ -123,14 +123,6 @@ PLAYER_FACTION_COLORS =
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};
WOW_PROJECT_MAINLINE = 1;
WOW_PROJECT_CLASSIC = 2;
WOW_PROJECT_BURNING_CRUSADE_CLASSIC = 5;
WOW_PROJECT_WRATH_CLASSIC = 11;
WOW_PROJECT_CATACLYSM_CLASSIC = 14;
WOW_PROJECT_MISTS_CLASSIC = 19;
WOW_PROJECT_ID = WOW_PROJECT_CLASSIC;
--
-- Glyph
--
-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
@@ -0,0 +1,855 @@
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
-- profession link. The link is built entirely in the DLL
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
-- this file only intercepts clicks on received links and renders the linker's
-- recipe list in a scroll frame (the UI half).
--
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
--
-- "Which recipes are known" is a bitfield over the skill line's canonical
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
-- the reader can craft themselves.
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
-- vanilla 384x512 single-column art, so on that client we fall back to the
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
-- than stretch the wide art. LO holds every layout value that differs.
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
local FRAME_W = TURTLE and 768 or 384;
local FRAME_H = 512;
local NUM_ROWS = TURTLE and 19 or 8;
local ROW_HEIGHT = 16;
local LO = TURTLE and {
hit = { 2, 82, 2, 73 },
quads = {
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
},
close = { -77, -8 },
bar = { 596, 15, 76, -44 },
barBdr = { 604, 22 },
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
-- detail scroll frame: { x, y, width, height }. Tall enough that the
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
detail = { 371, -110, 340, 290 },
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
} or {
hit = { 0, 34, 0, 75 },
quads = {
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
},
close = { -29, -8 },
bar = { 268, 15, 73, -37 },
barBdr = { 278, 22 },
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
-- recipes scroll (clipping partial rows), like native.
detail = { 20, -234, 297, 176 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
sep = { 15, -221, 331 },
};
-- Reagents render in a 2-column grid inside the detail scroll child (see
-- the detail pane below). The detail is a real ScrollFrame like the
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
-- of one reagent row.
local REAGENT_ROW_HEIGHT = 43;
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
-- list/detail scrolls use the ClassTrainer templates (they add the
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
-- doesn't want that groove, so it keeps the plain templates. Both detail
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
or "ClassTrainerListScrollFrameTemplate";
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
or "ClassTrainerDetailScrollFrameTemplate";
local PROFESSION_ICONS = {
[171] = "Trade_Alchemy",
[164] = "Trade_BlackSmithing",
[185] = "INV_Misc_Food_15", -- Cooking
[333] = "Trade_Engraving", -- Enchanting
[202] = "Trade_Engineering",
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
[197] = "Trade_Tailoring",
[186] = "Trade_Mining",
[182] = "Trade_Herbalism",
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
[356] = "Trade_Fishing",
};
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
local function ProfessionIcon(skillLineID)
local icon = PROFESSION_ICONS[skillLineID];
return icon and ("Interface\\Icons\\" .. icon)
or "Interface\\Icons\\INV_Misc_QuestionMark";
end
local function DifficultyBand(skill, hi, lo)
if not hi or hi <= 0 then
return 3;
end
if not lo or lo == 0 then
lo = (hi ~= 25) and (hi - 25) or 0;
end
local yellow = (hi + lo) / 2;
if skill < lo then
return 0; -- orange (optimal)
elseif skill < yellow then
return 1; -- yellow (medium)
elseif skill < hi then
return 2; -- green (easy)
end
return 3; -- grey (trivial)
end
local BAND_COLOR = {
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
[2] = EASY_DIFFICULTY_COLOR, -- green
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
};
local function CraftableCount(entry)
local reagents = entry.reagents;
if not reagents or table.getn(reagents) == 0 then
return nil;
end
local best;
for i = 1, table.getn(reagents) do
local r = reagents[i];
local need = r.count or 1;
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
if not best or n < best then
best = n;
end
end
return best;
end
local function ParseTradeLink(link)
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
local _, _, id, cur, max, who, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
if not id then
-- Legacy format (no name): trade:id:cur:max:bits
who = nil;
_, _, id, cur, max, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
end
if not id then
return nil;
end
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
end
------------------------------------------------------------------------------
-- Display frame (built lazily on first use)
------------------------------------------------------------------------------
local frame;
local function ResolveSubclasses(list, onLoaded)
local present, subs, anyItem = {}, {}, false;
for i = 1, table.getn(list) do
local r = list[i];
if r.createdItem and r.createdItem > 0 then
anyItem = true;
if not r.subclass then
-- GetItemInfoInstant's 3rd return is the localized subclass
-- name (or nil when uncached).
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
if subType and subType ~= "" then
r.subclass = subType;
elseif onLoaded then
-- Not cached — re-derive when the item loads.
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
end
end
if r.subclass and not present[r.subclass] then
present[r.subclass] = true;
table.insert(subs, r.subclass);
end
end
end
table.sort(subs);
return subs, anyItem;
end
local SUBCLASS_ALL = {};
local function SubClassButton_OnClick()
local v = this.value;
if v == SUBCLASS_ALL then
v = nil;
end
frame.subclassFilter = v;
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
frame:ApplyFilter();
end
local function SubClassDropDown_Initialize()
local info = {};
info.text = ALL_SUBCLASSES;
info.value = SUBCLASS_ALL;
info.checked = (frame.subclassFilter == nil);
info.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(info);
local subs = frame.subclasses or {};
for i = 1, table.getn(subs) do
local b = {};
b.text = subs[i];
b.value = subs[i];
b.checked = (frame.subclassFilter == subs[i]);
b.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(b);
end
end
local function RefreshSubclasses()
if not (frame and frame:IsShown() and frame.recipes) then
return;
end
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
frame.subclasses = subs;
if anyItem then
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
UIDropDownMenu_SetWidth(120, frame.dropdown);
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
frame.dropdown:Show();
else
frame.dropdown:Hide();
end
if frame.subclassFilter then
frame:ApplyFilter();
end
end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
f:RegisterForDrag("LeftButton");
f:SetScript("OnDragStart", function() this:StartMoving(); end);
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
f:SetFrameStrata("DIALOG");
f:Hide();
tinsert(UISpecialFrames, f:GetName())
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
f.portrait = portrait;
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture(q[1]);
tx:SetSize(q[2], q[3]);
tx:SetPoint(q[4]);
end
-- Horizontal divider between the recipe list and the detail pane.
-- Stock only (Turtle's HD frame art already has its own).
if LO.sep then
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
-- bottom half empty). So sample the top quarter, stretched to width —
-- the same region the native TradeSkillFrame uses for this divider.
local bar = f:CreateTexture(nil, "ARTWORK");
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
bar:SetTexCoord(0, 1, 0, 0.25);
bar:SetSize(LO.sep[3], 16);
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
title:SetPoint("TOP", 0, -18);
f.title = title;
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
bar:SetSize(LO.bar[1], LO.bar[2]);
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
bar:SetMinMaxValues(0, 1);
local barBg = bar:CreateTexture(nil, "BACKGROUND");
barBg:SetAllPoints(bar);
barBg:SetTexture(1, 1, 1, 0.2);
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
local border = CreateFrame("Frame", nil, bar);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
});
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
barName:SetPoint("LEFT", 6, 1);
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
barRank:SetJustifyH("LEFT");
barRank:SetWidth(128);
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
f.bar = bar;
f.barName = barName;
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, LIST_SCROLL_TEMPLATE);
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
scroll:SetScript("OnVerticalScroll", function()
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
end)
f.scroll = scroll;
f.rows = {};
for i = 1, NUM_ROWS do
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
local row = CreateFrame("Button", rowName, f,
"ClassTrainerSkillButtonTemplate");
row:SetWidth(290); -- height (16) comes from the template
if i == 1 then
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
else
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
end
row:SetNormalTexture("");
row:SetDisabledTexture("");
row:SetHighlightTexture("");
local txt = getglobal(rowName .. "Text");
if txt then
txt:ClearAllPoints();
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
end
local sel = row:CreateTexture(nil, "BACKGROUND");
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
sel:SetAllPoints(row);
sel:Hide();
row.select = sel;
-- Override the template's trainer scripts with ours.
row:SetScript("OnClick", function()
if this.entry then
f:SelectRecipe(this.entry);
end
end);
f.rows[i] = row;
end
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
-- auto-hides when the content fits (scrollBarHideable).
local detailScroll = CreateFrame("ScrollFrame",
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
detailChild:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetScrollChild(detailChild);
f.detailChild = detailChild;
local prodIcon = CreateFrame("Button", nil, detailChild);
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
prodIcon:SetPoint("TOPLEFT", 8, -3);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
-- Reagent buttons live in the scroll child (2 per row), built on
-- demand so any reagent count is handled and they scroll/clip with
-- the child.
f.reagents = {};
local function AcquireReagent(i)
if f.reagents[i] then
return f.reagents[i];
end
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
-- Extend the hit area rightward over the name plate so the tooltip
-- fires anywhere on the element, not just the icon. Stop short of the
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(114, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:Show();
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
end
function f:RenderDetail(entry)
local function itemInfo(id)
local item = Item:CreateFromItemID(id);
if not item:IsItemDataCached() then
item:ContinueOnItemLoad(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
self:RenderDetail(entry);
end
end);
end
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
end
local prodTex, prodText, prodLink;
local created = entry.createdItem or 0;
if created > 0 then
local name, tex, link = itemInfo(created);
prodText = name or entry.name;
prodTex = tex;
prodLink = link;
else
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
local descText = (created > 0) and ""
or (C_Spell.GetSpellDescription(entry.spellID) or "");
self.prodDesc:SetText(descText);
self.prodIcon:SetNormalTexture(prodTex or
"Interface\\Icons\\INV_Misc_QuestionMark");
self.prodName:SetText(prodText or "");
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
local made = entry.numMade or 0;
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
self.prodIcon:Show();
-- Fill every reagent slot; the scroll child clips/scrolls the
-- overflow. Buttons past the count are hidden.
local reagents = entry.reagents or {};
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
local name, tex, link = itemInfo(r.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
local recipes = self.displayed or {};
local total = table.getn(recipes);
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
local offset = FauxScrollFrame_GetOffset(self.scroll);
for i = 1, NUM_ROWS do
local row = self.rows[i];
local entry = recipes[offset + i];
if entry then
-- Append " [N]" when the reader can craft any (Blizzard's list).
local craftable = CraftableCount(entry);
local label = entry.name or "";
if craftable and craftable > 0 then
label = label .. " [" .. craftable .. "]";
end
row:SetText(label);
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
row.select:SetVertexColor(r, g, b);
row.select:Show();
row:SetTextColor(1, 1, 1);
else
row.select:Hide();
row:SetTextColor(r, g, b);
end
row.spellID = entry.spellID;
row.entry = entry;
row:Show();
else
row.spellID = nil;
row.entry = nil;
row.select:Hide();
row:Hide();
end
end
end
function f:ApplyFilter()
local src = self.recipes or {};
if not self.subclassFilter then
self.displayed = src;
else
local out = {};
for i = 1, table.getn(src) do
if src[i].subclass == self.subclassFilter then
table.insert(out, src[i]);
end
end
self.displayed = out;
end
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
if sb then
sb:SetValue(0);
end
self:Refresh();
end
f:RegisterEvent("BAG_UPDATE_DELAYED");
f:SetScript("OnEvent", function()
if not f:IsShown() then
return;
end
f:Refresh();
if f.selectedEntry then
f:RenderDetail(f.selectedEntry);
end
end);
return f;
end
local function ShowTradeSkillLink(link, text)
local id, cur, max, who, bits = ParseTradeLink(link);
if not id then
return false;
end
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
if not recipes then
return false;
end
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
local total = table.getn(recipes);
local knownList = {};
for i = 1, total do
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
table.sort(knownList, function(a, b)
if a.band ~= b.band then
return a.band < b.band;
end
return a.name < b.name;
end);
local known = table.getn(knownList);
if not frame then
frame = CreateFrame_TradeSkillLink();
end
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
frame.recipes = knownList;
frame.subclassFilter = nil;
frame:ClearDetail();
frame:Show();
RefreshSubclasses();
frame:ApplyFilter();
local first = frame.displayed and frame.displayed[1];
if first then
frame:SelectRecipe(first);
end
return true;
end
------------------------------------------------------------------------------
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
-- in 1.12 only knows item:/enchant: and would reject the link).
------------------------------------------------------------------------------
local originalSetItemRef = SetItemRef;
function SetItemRef(link, text, button, chatFrame)
if type(link) == "string" and string.sub(link, 1, 6) == "trade:" then
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(text);
return;
end
if ShowTradeSkillLink(link, text) then
return;
end
end
return originalSetItemRef(link, text, button, chatFrame);
end
local function CreateProfessionLinkButton(parentName, getLink)
local parent = _G[parentName];
local btnName = parentName .. "LinkButton";
if _G[btnName] or not parent then
return;
end
local title = _G[parentName .. "TitleText"];
local btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
local function reposition()
if not title then
return;
end
btn:ClearAllPoints();
if title:GetJustifyH() == "LEFT" then
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
else
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
end
end
reposition()
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
btn:SetNormalTexture(icon);
btn:SetHighlightTexture(icon, "ADD");
btn:SetScript("OnClick", function()
local link = getLink();
if not link then
return;
end
if not ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Show();
end
ChatFrameEditBox:Insert(link);
end);
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT");
GameTooltip:SetText(LINK_TRADESKILL_TOOLTIP, nil, nil, nil, nil, 1);
GameTooltip:Show();
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
CreateProfessionLinkButton("ATSWFrame", function()
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
end)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
CreateProfessionLinkButton("ATSWFrame", function()
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
end)
end)
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
ITEM_MOD_AGILITY_SHORT = "Beweglichkeit";
ITEM_MOD_STRENGTH_SHORT = "Stärke";
ITEM_MOD_INTELLECT_SHORT = "Intelligenz";
ITEM_MOD_SPIRIT_SHORT = "Willenskraft";
ITEM_MOD_STAMINA_SHORT = "Ausdauer";
ITEM_MOD_ATTACK_POWER_SHORT = "Angriffskraft";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Distanzangriffskraft";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Kritischer Trefferwert (Nah)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Kritischer Trefferwert (Distanz)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Trefferwert (Nah)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Trefferwert (Distanz)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Trefferwert (Zauber)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Kritischer Trefferwert (Zauber)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonusschaden";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonusheilung";
ITEM_MOD_MANA_REGENERATION_SHORT = "Manaregeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Verteidigung";
ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/fokus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
+25
View File
@@ -7,6 +7,30 @@ GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
ITEM_MOD_AGILITY_SHORT = "Agility";
ITEM_MOD_STRENGTH_SHORT = "Strength";
ITEM_MOD_INTELLECT_SHORT = "Intellect";
ITEM_MOD_SPIRIT_SHORT = "Spirit";
ITEM_MOD_STAMINA_SHORT = "Stamina";
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
@@ -24,3 +48,4 @@ SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
TARGETFOCUS = "Fijar unidad objetivo";
TARGETFOCUS = "Fijar unidad objetivo";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
ITEM_MOD_AGILITY_SHORT = "agilidad";
ITEM_MOD_STRENGTH_SHORT = "fuerza";
ITEM_MOD_INTELLECT_SHORT = "intelecto";
ITEM_MOD_SPIRIT_SHORT = "espíritu";
ITEM_MOD_STAMINA_SHORT = "aguante";
ITEM_MOD_ATTACK_POWER_SHORT = "Poder de ataque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Poder de ataque a distancia";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Golpe crítico (cuerpo a cuerpo)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Golpe crítico (a distancia)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Golpe (cuerpo a cuerpo)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Golpe (a distancia)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Golpe (hechizo)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Golpe crítico (hechizo)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bon. daño";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bon. sanac.";
ITEM_MOD_MANA_REGENERATION_SHORT = "Regeneración de maná";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defensa";
ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/enfoque";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
ITEM_MOD_AGILITY_SHORT = "Agilité";
ITEM_MOD_STRENGTH_SHORT = "Force";
ITEM_MOD_INTELLECT_SHORT = "Intelligence";
ITEM_MOD_SPIRIT_SHORT = "Esprit";
ITEM_MOD_STAMINA_SHORT = "Endurance";
ITEM_MOD_ATTACK_POWER_SHORT = "Puissance d'attaque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Puissance d'attaque à distance";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Score de critique (mêlée)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Score de critique (distance)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Toucher (mêlée)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Toucher (distance)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Toucher (sorts)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Score de critique (sorts)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonus aux dégâts";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonus aux soins";
ITEM_MOD_MANA_REGENERATION_SHORT = "Régénération du mana";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Défense";
ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focal";
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов;
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
TARGETFOCUS = "Восстановить цель";
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
ITEM_MOD_AGILITY_SHORT = "к ловкости";
ITEM_MOD_STRENGTH_SHORT = "к силе";
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
ITEM_MOD_SPIRIT_SHORT = "к духу";
ITEM_MOD_STAMINA_SHORT = "к выносливости";
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/фокус";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻擊強度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "遠程攻擊強度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "致命一擊(近戰)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "致命一擊(遠程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近戰)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(遠程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法術)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "致命一擊(法術)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "傷害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治療加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力恢復";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防禦";
ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/專注目標";
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
+25 -6
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,7 +102,23 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
# 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.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+36 -14
View File
@@ -18,47 +18,57 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [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` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [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.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `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` |
| [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`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [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` |
| [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.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [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`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
| [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` |
### GlueXML calls
@@ -107,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` |
@@ -116,11 +127,16 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
@@ -130,8 +146,14 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
+247 -2
View File
@@ -2725,7 +2725,26 @@ matching modern's 3-tuple. `CastSpellNoToggle("<name>")` macros are
also recognized because `Spell::CastNoToggle` already hooks the same
parser to register that pattern.
## 88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event
## ~~88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** the 2026-05-22
investigation kept trying to hook a FIRER of UNIT_INVENTORY_CHANGED.
The winning move was the opposite: the engine has a generic
descriptor-field observer system (the "__AUCMirrorHandler__" nodes —
registrar `FUN_00467E70`, dispatcher `FUN_00465570` with per-node
mirror + memcmp change detection). `FUN_004F8CC0` registers observers
for the bag/backpack/bank/keyring GUID ranges but never for equipment
`0x4A8..0x538` — so we co-hook it and REGISTER OUR OWN observer per
equipment field, mirroring the engine's registration exactly (bank 4,
size 8, player GUID). Callback ABI verified from the dispatcher call
site at `0x004655DC` + `FUN_004F8DB0`'s `RET 0x10`:
`__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
oldValue*, userArg)`. hasCurrent read from the invMgr GUID array (the
same "current truth" the engine's bag callback reads). No polling, no
hot hook, engine-managed lifetime (setup re-runs each enter-world).
Offsets documented at `FUN_DESC_OBSERVER_REGISTER` in Offsets.h.
Original notes below kept for the investigation trail.
WotLK-era event that fires every time a paperdoll slot changes
(equip, unequip, swap). Heavily used by gear-tracking,
@@ -2828,7 +2847,33 @@ Same diff feeds `EQUIPMENT_SETS_CHANGED` /
module — currently those fire only on user-side mutations, not
on inventory changes that affect a set's "equipped count".
## 89. `UPDATE_INVENTORY_DURABILITY` event
## ~~89. `UPDATE_INVENTORY_DURABILITY` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** co-hook the
engine's inventory-alerts recompute `FUN_004C7EE0` — the routine that
walks equipment slots, reads each item's durability/broken state from
the descriptor, and unconditionally fires vanilla's
`UPDATE_INVENTORY_ALERTS` (event 501). The engine calls it whenever an
owned item's fields change (the item post-update handler `FUN_005D9A90`
routes every item values-update through it, alongside
UNIT_INVENTORY_CHANGED and the bag-update chain) plus equip and
enter-world paths — i.e., it IS the engine's own "durability may have
changed" signal. Post-original we diff a per-slot {guid, durability}
snapshot (seeded silently on the first recompute per enter-world) and
fire once per recompute with a real change. No polling, no hot hook.
**Dead end tried first:** bank-1 (ITEM field) descriptor observers per
equipped item, via the TODO #88 observer system. They register without
error but are NEVER invoked — item values-updates demonstrably arrive
(probed the values pre-pass `FUN_00465330`) and player-bank observers
fire in the same session; registration arithmetic fully verified (bank
base `FUN_00465690(1)=0x18`, bank walk item 0→1→2, anchors match
dispatch). Prime suspect: the registrar's internal wrapper lookup
(`FUN_00464890`, the observer registry — NOT the object manager)
silently no-ops for item objects. See the DEAD END note at
`OFF_DESC_ITEM_DURABILITY` in Offsets.h.
Original notes below kept for the investigation trail.
Fires when any equipped item's durability changes — picks up
combat damage, repairs, item destruction. Used by Repair-O-Matic
@@ -3570,3 +3615,203 @@ vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
lines inherit height/spacing identical to the template's 1..30 so the
tooltip grows seamlessly. Both are observable on the first >30-line
hover.
## 98. `UnitThreatSituation` / `UnitDetailedThreatSituation` / `GetThreatStatusColor` — NOT FEASIBLE in a client DLL
Modern (WotLK+) exposes three threat APIs:
[`UnitThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitThreatSituation)
(unit → 0..3 aggro status),
[`UnitDetailedThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitDetailedThreatSituation)
(isTanking, status, threatPct, rawThreatPct, threatValue), and
[`GetThreatStatusColor`](https://warcraft.wiki.gg/wiki/API_GetThreatStatusColor)
(status → RGB). None of them can be backed by ClassicAPI, because
**the underlying data does not exist on the 1.12 client** — not in a
form a DLL can read, and not by any amount of RE.
### Threat is 100% server-authoritative in vanilla; no threat opcode exists
Vanilla 1.12's threat lives entirely inside the server's
`ThreatManager` (per-creature `ThreatList` of `HostileReference`s —
see Turtle's
[`src/game/Threat/ThreatManager.cpp`](C:\Git\turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Threat\ThreatManager.cpp)).
The 3.3.5 client can show a threat meter because WotLK added
`SMSG_THREAT_UPDATE` / `SMSG_HIGHEST_THREAT_UPDATE` (and the client
keeps a per-unit threat table those packets feed). **1.12 has no such
opcode** — the protocol never transmits per-unit threat values, so
there is nothing in client memory to read. This is the same class of
limitation as `C_QuestLog.IsQuestFlaggedCompleted` (TODO #1): the
client simply isn't told. Unlike cast timing (TODO #91, recoverable
from `SMSG_SPELL_START`) or aura casters (recoverable from
`SMSG_SPELL_GO`), there is **no incidental packet** that leaks threat
either — the server computes tanking/aggro decisions internally and
broadcasts none of the numbers.
### How Turtle actually does it — a *server-side* addon-channel protocol
TWThreat (`C:\WoW\Octo\Interface\Addons\TWThreat`) is not reading
client memory; it round-trips through a **custom server patch**:
1. Client → server: the addon `SendAddonMessage("TWT_UDTSv4"[.._TM],
"limit=NN", "PARTY"/"RAID")` — a normal `CMSG_MESSAGECHAT` addon
message (`TWThreat.lua:1207` `TWT.UnitDetailedThreatSituation`).
2. Server intercepts the prefix in
[`ChatHandler.cpp:1051`](turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Handlers\ChatHandler.cpp)
(`strstr(msg, "TWT_UDTSv4")`), validates
`GetSelectedCreature()->CanHaveThreatList()`, and calls
`ThreatManager::UnitDetailedThreatSituation(creature, player, limit,
tankMode)`.
3. That serializer (`ThreatManager.cpp:441`) walks the real
`getThreatList()` and builds a `TWTv4=name:pct:threat;…` string,
then `SendAddonMessage`s it back.
4. Client parses it in `CHAT_MSG_ADDON` → `TWT.handleThreatPacket`
(`TWThreat.lua:790`).
Every threat number the meter shows originated on the server and was
put on the wire by a server-side code change ClassicAPI has no
counterpart to. A client DLL cannot manufacture data the server never
sends.
### Even where a server *does* expose it, the transport is async
The Turtle path is a request/response over the addon chat channel —
fundamentally asynchronous (poll on `OnUpdate`, handle the reply on a
later frame). It cannot back a **synchronous** `UnitThreatSituation()`
that returns a value in the same call, which is exactly why TWThreat is
structured around events and a cached `TWT.threats` table rather than a
blocking query. A ClassicAPI shim would at best have to fake a
synchronous return from a stale cache, and the cache only exists if a
Turtle-style server is feeding it — i.e. the shim would add nothing the
addon isn't already doing in Lua.
### `GetThreatStatusColor` is the only client-pure piece — and it's trivial Lua
`GetThreatStatusColor(status)` is just a fixed 0..3 → RGB map (grey /
yellow / orange / red); no engine data involved. But it's meaningless
without a real `status` to feed it, and it's a two-line Lua table
lookup — no reason to spend a DLL global on it. If we ever ship a
threat polyfill it belongs beside the (Lua, server-dependent) status
source, not in the DLL.
**Verdict:** skip all three. The data is server-only with no vanilla
opcode, the only working implementation (Turtle) is a server patch plus
an async addon-channel protocol that lives correctly in Lua, and the
one client-pure function is a trivial color table. Nothing here is a
client-DLL problem.
### If we synthesized it anyway
"Can't read it" and "can't estimate it" are separate claims. We can't
read threat (above); *estimating* it is possible, but it splits into two
tiers, and neither one is a DLL feature.
**Tier 1 — client-only proxy from the mob's target (small, honest,
nearly useless).** The one threat-correlated fact vanilla *does* sync to
the client is the creature's current target (`UNIT_FIELD_TARGET` in the
descriptor — the same field `"<mob>target"` / `targettarget` reads). A
mob attacks its highest-threat valid target, so `mobTarget == unit` ≈
"unit is tanking". That yields:
- `UnitThreatSituation(unit, mob)` → `3` when the mob targets `unit`,
else `nil`. It **cannot** produce `1` ("higher than tank, about to
pull") or distinguish `2` from `3` — those are numeric comparisons and
we have no numbers.
- `UnitDetailedThreatSituation` → `isTanking` only; `status` degenerate,
`threatpct` / `rawthreatpct` / `threatvalue` all unavailable.
- Laggy: it changes only when the mob switches target (a descriptor
update), so it *trails* real threat and gives zero predictive warning
— the entire reason a threat meter exists.
This needs no DLL: `UnitIsUnit("<mob>target", unit)` already does it in
pure Lua. Shipping it under the real API names would be actively
misleading (callers expect the 0–3 gradient plus percentages), so it's
not worth it even as a stub.
**Tier 2 — a real threat model (accurate-ish, huge, cooperative, ~95%
Lua).** This is what KTM / KLHThreatMeter / vanilla-Omen actually do, and
the only synthesis that fills the percentage/prediction dimensions the
API is *for*:
1. **Model your own threat.** Parse your outgoing combat events and
convert them to threat via a per-spell coefficient database (damage ≈
1 threat per point, healing ≈ 0.5/HP applied to *every* mob in combat,
flat-threat abilities like Sunder Armor / Demo Shout, per-spell
bonus/penalty coefficients), then apply your active multipliers —
stance/form (Defensive 1.3×, Berserker 0.8×, Bear/Cat), Righteous Fury
(+Holy threat), Blessing of Salvation (0.7×), threat-reduction
trinkets — and handle the sets/wipes: taunt (sets you to current top),
Feign Death, Vanish, Fade, Cower.
2. **Broadcast** your per-mob estimate over `SendAddonMessage` and
**aggregate** everyone else's broadcasts into a per-mob table.
3. Derive `isTanking` / `status` / the percentages by comparing each
member to the current tank against the **110% melee / 130% ranged**
pull threshold — the exact constants both Turtle's server serializer
(`ThreatManager.cpp:494`, `threatValue*100 / (tankThreat * (isMelee ?
1.1 : 1.3))`) and TWThreat's client fallback (`TWThreat.lua:932`)
use. Worth stealing verbatim so a synthesized `threatpct` lines up
with what real-threat clients show.
Why it isn't ours to build:
- The coefficient DB is **game-knowledge, not in the binary** — you
cannot RE it out of the client; it's the classic hand-maintained
Threat-1.0/KTM data. The parsing, broadcast, and aggregation are all
Lua. The talent/aura/stance *inputs* are already exposed by existing
ClassicAPI functions (`GetTalentInfo`, the aura APIs, `IsPlayerSpell`,
the spell-mod reads). **None of it is a client-DLL problem.**
- **Cooperative-only.** You can precisely model only members running the
same addon and broadcasting; a non-participating tank is invisible (the
original KTM caveat). The intuitive objection — "I can see everyone's
damage in the combat log, why can't I model them from one client?" —
fails for three compounding reasons, none liftable by the DLL:
1. **You don't actually see everyone's events.** Vanilla's combat log
is visibility/range-gated — the server only broadcasts combat events
for units near you, not the whole raid. A stacked 5-man logs most of
it (so a single-observer model degrades *gracefully* in dungeons); a
spread 40-man leaves you blind to the ranged camp, the back-line
healers, anyone outside your bubble. The DLL can't recover these —
the server never sends the packets, so there's nothing to hook.
2. **Threat ≠ damage, and the missing factor is hidden.** Threat is
`damage × stance × talents × buffs`, not 1:1. The talent factor
(Defiance, Subtlety, Feral Instinct, Shadow Affinity, Frost
Channeling — the passive threat-mod talents) is **unreadable for
other players in vanilla**; there's no inspect path to it. Stance /
Blessing of Salvation / Fade are at least observable auras when the
unit is in range, but talents are pure hidden state, so an observer
can't derive a stranger's coefficient from their damage.
3. **Much threat has no attributable log line.** In-combat healing
generates threat on *every* mob the healer is engaged with (not the
mob the heal touched); buffs / HoTs / PW:Fortitude, flat-threat
abilities, and pet threat either don't appear in the log or can't be
pinned to the right mob from a single line.
Broadcasting sidesteps all three because each client computes threat
from *private, authoritative* knowledge — its own exact casts, its own
talents, its own active stance/buffs — then shares the finished number.
The Tier-1 mob-target read is worth keeping here *only* as a cross-check
— it reveals who actually holds aggro regardless of who's broadcasting.
- **It's an estimate that drifts.** Addon threat models desync from the
server's true numbers (rounding, unmodeled effects, out-of-range or
missed events). That drift is precisely why Blizzard later added
`SMSG_THREAT_UPDATE` and why Turtle chose a server serializer over
blessing an addon guess.
**The only DLL-shaped angle — and it's an *input*, not threat.** The one
place a DLL beats Lua is the feed: vanilla's combat log is localized text
keyed by unit *name* (ambiguous with duplicate names, breaks per-locale).
Hooking the damage/heal packet handlers (`SMSG_SPELLNONMELEEDAMAGELOG`,
`SMSG_ATTACKERSTATEUPDATE`, `SMSG_PERIODICAURALOG`) to emit precise,
GUID-keyed, unlocalized damage/heal events would make any Lua threat
model far more robust. But (a) that's a **separate feature** — a
`COMBAT_LOG_EVENT`-style feed, useful well beyond threat — that should be
filed on its own merits; (b) it still only *feeds* the Lua model — threat
stays an estimate, computed cooperatively in Lua; and (c) on the actual
target environment SuperWoW already exposes GUID-keyed combat info +
`UNIT_CASTEVENT`, so even this niche is largely pre-filled.
**Bottom line on synthesis:** Tier 1 is a Lua one-liner too degenerate to
wear the API's name; Tier 2 is a full KTM/Omen reimplementation that is
almost entirely Lua data + logic, cooperative-only, and inherently an
estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
+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
+2637 -266
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -56,7 +56,8 @@ namespace Lua {
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+1029 -25
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -41,6 +41,11 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
int *outStatus, int *outDepHandle,
const char *accountName);
using GetAccountName_t = const char *(*)();
// Hash-lookup by name → the matched record's RequiredDeps descriptor
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
// first bytes are the inline name). We use it only to recover the
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
// Resolves the user's arg to a `const uint8_t *` that the engine's
// per-field accessors can consume. They all read the addon name as
@@ -295,6 +300,63 @@ int __fastcall Script_DoesAddOnExist(void *L) {
return 1;
}
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
// `## OptionalDeps:` names as multiple return values, in declared order.
// The exact shape of the stock `GetAddOnDependencies`, but reading the
// *optional* dep array instead of the required one. No optional deps →
// returns nothing.
//
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
// into a `{cap, count, data, quantum}` name-array descriptor at
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
//
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
// raises the usage error; an unknown *name* string returns nothing
// (no error), matching the engine's own not-found path.
//
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
// *name pointer*, NOT the record — reading dep offsets off it lands in
// string memory and crashes (a numeric-path caller like pfUI's addon
// list hit exactly that). The resolver returns the RequiredDeps
// descriptor at `record+0x48`; subtract to recover the base. An unknown
// name yields NULL → return nothing (no error), like stock.
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
static constexpr const char *kUsage =
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
// stock raises the usage error in both cases.
const uint8_t *nameOrEntry = ResolveAddOnName(L);
if (nameOrEntry == nullptr) {
Game::Lua::Error(L, "%s", kUsage);
return 0;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
if (reqDesc == nullptr)
return 0; // unknown addon name — no error, push nothing
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
if (count == 0)
return 0;
auto names = *reinterpret_cast<const char *const *const *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
if (names == nullptr)
return 0;
for (uint32_t i = 0; i < count; ++i)
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
return static_cast<int>(count);
}
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
// returns. Slots 0..2 match the engine's own ordering at
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
@@ -323,6 +385,8 @@ static void RegisterLuaFunctions() {
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+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
+259
View File
@@ -0,0 +1,259 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// See ComboDuration.h for the contract and the protocol findings that
// motivate the design (the server never transmits target-debuff
// durations in 1.12).
#include "ComboDuration.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "spell/Mod.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::ComboDuration {
namespace {
using Net::CDataStore;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
// The player's live combo points, read the same way Script_GetComboPoints
// (0x0051A190) does: the CP byte on the CGPlayer +0xE68 sub-struct. The player
// comes from the shared Unit::Identity::PlayerObject() (the object-manager GUID
// resolve, our fastest player-object path — returns null pre-world rather than
// throwing), so this stays safe from any context the send hook can fire in.
uint8_t CurrentComboPoints() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
const uint8_t *info = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (info == nullptr)
return 0;
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
}
// One cast in flight at a time is the practical case (a second finisher
// can't be cast until the server consumes the first one's combo points),
// so a single slot with a spellID match and a short TTL suffices.
struct Capture {
uint32_t spellId;
uint8_t comboPoints;
uint32_t capturedMs;
};
Capture g_capture{0, 0, 0};
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
// stale (the cast failed / was rejected) and must not scale a later cast.
constexpr uint32_t kCaptureTtlMs = 2000;
int ConsumeCapturedCP(uint32_t spellId) {
const Capture c = g_capture;
g_capture = {0, 0, 0};
if (c.spellId != spellId || c.comboPoints == 0)
return 0;
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
return 0;
return c.comboPoints;
}
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
// passes through, including casts released from nampower's spell queue
// (which bypass any co-hook on Spell_C_CastSpell itself via trampoline
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
// Lua-registered overrides for spells whose client DBC row is missing or
// wrong. Turtle's reworked Rip is the motivating case: its DurationIndex
// dangles (records[87] == NULL in Turtle's SpellDuration.dbc) because the
// rework lives entirely server-side, so the values must come from config.
struct Override {
uint32_t spellId;
int32_t baseMs;
int32_t maxMs;
};
constexpr int kMaxOverrides = 64;
Override g_overrides[kMaxOverrides];
int g_overrideCount = 0;
const Override *FindOverride(uint32_t spellId) {
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId)
return &g_overrides[i];
}
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
// -> true on success. Re-registering a spellID replaces its entry.
// Values are in seconds to match the C_UnitAuras duration convention;
// the formula applied is the server's own:
// `base + (max - base) * comboPoints / 5`.
int __fastcall Script_RegisterComboDuration(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterComboDuration(spellID, "
"baseSeconds, maxSeconds)");
return 0;
}
const auto spellId = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto baseMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 2) * 1000.0);
const auto maxMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 3) * 1000.0);
if (spellId == 0 || baseMs < 0 || maxMs <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId) {
g_overrides[i] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
}
if (g_overrideCount >= kMaxOverrides) {
Game::Lua::PushBool(L, false);
return 1;
}
g_overrides[g_overrideCount++] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterComboDuration",
&Script_RegisterComboDuration);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
const int cp = ConsumeCapturedCP(spellId);
if (cp <= 0 || cp > 5)
return 0;
int32_t baseMs = 0;
int32_t maxMs = 0;
if (const Override *o = FindOverride(spellId)) {
baseMs = o->baseMs;
maxMs = o->maxMs;
} else {
const int idx = *reinterpret_cast<const int *>(
spellRecord + Offsets::OFF_SPELL_RECORD_DURATION_INDEX);
const uint8_t *row =
DBC::Record(Offsets::VAR_SPELL_DURATION_RECORDS,
Offsets::VAR_SPELL_DURATION_COUNT,
static_cast<uint32_t>(idx));
if (row != nullptr) {
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
}
// The server scales only when the row has a combo range: base != max
// ((v)mangos CalculateSpellDuration). base < 0 is the "infinite"
// sentinel; fixed-duration rows (base == max) take the regular path.
if (baseMs < 0 || maxMs <= 0 || baseMs == maxMs)
return 0;
int32_t durationMs = baseMs + (maxMs - baseMs) * cp / 5;
if (durationMs <= 0)
return 0;
// Player duration SpellMods apply on top of the combo-scaled value,
// matching the server's order (scale, then ApplySpellMod).
durationMs = static_cast<int32_t>(Spell::Mod::Apply(
spellRecord, Offsets::SPELLMOD_OP_DURATION,
static_cast<float>(durationMs)));
return durationMs > 0 ? static_cast<uint32_t>(durationMs) : 0;
}
} // namespace Aura::ComboDuration
+50
View File
@@ -0,0 +1,50 @@
// 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>
// `Aura::ComboDuration` — combo-point-scaled durations for finisher
// auras (Rupture, Kidney Shot, Slice and Dice, Rip, …).
//
// The 1.12 server computes a finisher's real duration at cast time as
// `base + (max - base) * comboPoints / 5` from the spell's
// SpellDuration.dbc row and never transmits the result for auras the
// player puts on OTHER units (verified with a whole-protocol sniff:
// SMSG_UPDATE_AURA_DURATION is self-scoped, and nothing else carries
// it). So this module mirrors the computation client-side:
//
// - a co-hook on the NetClient send (`FUN_NET_SEND`) snapshots the
// player's combo points the instant CMSG_CAST_SPELL leaves — the
// one moment the count is still intact (the server clears it
// before SMSG_SPELL_GO comes back);
// - `TryComboScaledMs` consumes that snapshot when `Aura::Source`'s
// SpellGo hook stores the aura, computing the scaled duration from
// SpellDuration.dbc and applying the player's duration SpellMods on
// top, matching the server's order of operations. Spells whose
// duration index DANGLES (client data bug — Turtle's reworked Rip
// is the known case) fall back to built-in values gated on that
// exact condition; a Lua-registered override
// (`C_UnitAuras.RegisterComboDuration`) beats both, as an escape
// hatch for other servers whose values differ from the client DBC.
namespace Aura::ComboDuration {
// Combo-scaled duration (ms) for a player-cast spell, or 0 when it
// doesn't apply: no combo points captured for this cast, the spell's
// duration row isn't combo-scaled (base == max) and has no override,
// or the row is missing. A 0 return means "use the regular base
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
} // namespace Aura::ComboDuration
+60 -4
View File
@@ -492,6 +492,50 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
// descriptor holds at least one raw aura the unit is in view and fully synced,
// so it's authoritative — any cached entry for the unit whose spell isn't in
// the array was removed while we couldn't see it (e.g. cancelled out of range,
// so no OnAuraRemoved reached us) and must be dropped before the fallback
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
// but flag-hidden is still legitimately on the unit. Skips when the array is
// empty — that can't be told apart from an out-of-range descriptor drop, and
// reconciling then would wipe still-valid out-of-range entries.
void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
// range. The cache fallback is only trustworthy when the descriptor has been
// wholesale-cleared: the two states it exists for — rogue stealth and party
// range fluctuation — clear the *entire* aura array at once. If the unit shows
// even one live aura, the descriptor is authoritative, so a cache entry the
// descriptor lacks is a genuinely-gone aura we never saw removed (e.g. a
// no-duration debuff — Ghost/Spirit — on a unit that died and resurrected out
// of our visibility, so no OnAuraRemoved fired and its infinite entry never
// expired). Surfacing it then produces a stuck phantom.
bool DescriptorHasVisibleAura(const uint8_t *unit) {
if (unit == nullptr)
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (IsSlotPopulated(unit, slot))
return true;
}
return false;
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
if (unit == nullptr)
@@ -515,12 +559,18 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
// (no double-listing the same live aura) and, when player-filtered, must have
// been cast by us. Superseded / dispelled auras are kept out of the cache by
// `Aura::Source`'s removal eviction, not filtered here.
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
// the cache path must apply the same engine predicate or
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
@@ -534,6 +584,9 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
@@ -562,6 +615,9 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return;
+82 -5
View File
@@ -15,10 +15,12 @@
#include "Source.h"
#include "ComboDuration.h"
#include "Data.h"
#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"
@@ -149,10 +151,40 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
// records the current map without flushing.
int g_lastMapId = -1;
// Drop entries whose timed aura has elapsed so the table doesn't fill with
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
// until overwritten.
//
// Also flush the entire cache on a map-ID change. A battleground/instance/
// continent transition (including the teleport out of a BG via /afk) bulk-
// clears the player's aura descriptor WITHOUT firing per-aura OnAuraRemoved,
// so entries for auras that are now gone would linger and the
// GetAuraDataByIndex fallback would keep surfacing them — the "buffs/debuffs
// stuck after leaving a battleground" report. Every other unit from the old
// map has despawned too, and the local player's real auras re-sync into the
// descriptor / PLAYER_BUFF tables on entering the new world, so a full flush
// loses nothing legitimate. Crucially, the descriptor drops the fallback
// must survive — rogue stealth and party-range fluctuation — do NOT change
// the map ID, so they're unaffected.
void OnWorldTick() {
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
if (mapId != g_lastMapId) {
if (g_lastMapId != -1)
FlushAll();
g_lastMapId = mapId;
}
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
@@ -203,7 +235,15 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
// Combo-point finishers (Rupture, Kidney Shot, …) have their real
// duration computed at cast time from the combo points spent — the
// base-duration helper can't know it. ComboDuration mirrors the
// server's computation from the CP snapshot its send hook captured;
// 0 means "not combo-scaled", fall through to the base path.
uint32_t durationMs =
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
if (durationMs == 0)
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
if (numTargets == 0) {
@@ -249,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);
@@ -258,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{
@@ -278,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{
@@ -286,10 +337,18 @@ const Game::HookAutoRegister _hookAuraStacks{
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
// stack); `slot` is unused here — we evict by spellId.
// dispelled, was cancelled by its owner, or was replaced by a higher rank
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
// here — we evict by spellId.
//
// Always evict, even for a death-persistent aura (flask, world buff) on a
// dead unit: the server keeps those through death WITHOUT changing any
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
// for one is therefore genuine — e.g. the owner cancelled the flask while
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
// whose SetUnitAura tooltip is empty.
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
@@ -299,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{
@@ -322,6 +382,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
present = true;
break;
}
if (!present)
e.used = false;
}
}
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
return 0;
+12
View File
@@ -50,6 +50,18 @@ struct CachedAura {
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
// written. The classification is recorded from the aura's descriptor slot at
+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);
}
+7
View File
@@ -45,6 +45,8 @@
// `CursorHasItem` / engine `PickupX` calls when they need to detect
// that.
#include "cursor/Info.h"
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
@@ -273,4 +275,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool HasItem() {
return ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
}
} // namespace Cursor::Info
+24
View File
@@ -0,0 +1,24 @@
// 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 Cursor::Info {
// True if a GUID-bearing item is currently on the cursor —
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
// path reads and the exact signal the engine's item-pickup path guards on.
// Used to gate cursor pickups (don't clobber a held item).
bool HasItem();
} // namespace Cursor::Info
+29 -26
View File
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
// ---------------- encode ----------------
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
// switches to counting mode (end = NULL, accumulates bytes_needed) and
// encoding must CONTINUE so the final tally is complete — a nested
// container's tally only reaches the parent via close_container. So
// CborErrorOutOfMemory is not a failure here; the caller reads
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
bool EncOk(CborError err) {
return err == CborNoError || err == CborErrorOutOfMemory;
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
bool IsLuaArray(void *L, int idx, int &outLen) {
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
int arrayLen = 0;
if (IsLuaArray(L, idx, arrayLen)) {
CborEncoder arr;
if (cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen)) != CborNoError)
if (!EncOk(cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen))))
return false;
for (int i = 1; i <= arrayLen; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i));
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &arr) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &arr));
}
const int pairs = CountTablePairs(L, idx);
CborEncoder map;
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &map) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &map));
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
switch (Game::Lua::Type(L, idx)) {
case Game::Lua::TYPE_NIL:
return cbor_encode_null(enc) == CborNoError;
return EncOk(cbor_encode_null(enc));
case Game::Lua::TYPE_BOOLEAN:
return cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
return EncOk(cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0));
case Game::Lua::TYPE_NUMBER: {
const double n = Game::Lua::ToNumber(L, idx);
// Whole numbers in [-2^53, 2^53] go out as integers so
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
if (std::isfinite(n) && n == std::floor(n) &&
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
if (n >= 0.0)
return cbor_encode_uint(enc,
static_cast<uint64_t>(n)) == CborNoError;
return cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)) == CborNoError;
return EncOk(cbor_encode_uint(enc,
static_cast<uint64_t>(n)));
return EncOk(cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)));
}
return cbor_encode_double(enc, n) == CborNoError;
return EncOk(cbor_encode_double(enc, n));
}
case Game::Lua::TYPE_STRING: {
const char *s = Game::Lua::ToString(L, idx);
const unsigned int n = Game::Lua::StrLen(L, idx);
return cbor_encode_text_string(enc, s, n) == CborNoError;
return EncOk(cbor_encode_text_string(enc, s, n));
}
case Game::Lua::TYPE_TABLE:
return EncodeTable(L, idx, enc, depth);
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
return 0;
}
// tinycbor encodes into a fixed-size buffer; if it overflows, it
// tracks the extra bytes needed so we can resize and retry.
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
// encoding in counting mode (EncOk treats OutOfMemory as success) so
// the extra-bytes tally is complete, then we resize and retry once.
std::vector<uint8_t> buf(256);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
if (!EncodeValue(L, 1, &enc, 0)) {
// If it was a buffer-size issue we'll catch it via the
// extra-bytes-needed check below; otherwise this is a
// genuine "value not representable" failure.
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra == 0)
return 0;
buf.resize(buf.size() + extra);
continue;
}
if (!EncodeValue(L, 1, &enc, 0))
return 0; // genuine "value not representable" failure
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
buf.resize(buf.size() + extra);
+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);
};
+321
View File
@@ -0,0 +1,321 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Modern Region/Frame method backports.
//
// Modern addon ports routinely call these methods unconditionally —
// they're the C-level frame surface later clients take for granted.
// This module supplies the ones vanilla lacks (surfaced via
// AI_VoiceOver's modern code path, but generic to every port):
//
// - `region:SetPoint("RIGHT")` — the one-arg form. Vanilla's parser
// accepts every other modern form but errors when arg 3 is fully
// omitted; a co-hook on Script_SetPoint normalizes it to
// `(point, 0, 0)` (parent-relative, zero offsets — the exact modern
// semantic).
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
// - `frame:SetResizeBounds(minW, minH [, maxW, maxH])` — the modern
// rename of vanilla's SetMinResize/SetMaxResize pair.
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
// (`int __fastcall(void *L)`, self at index 1) — no re-derived setters,
// so pixel/UI-scale conversion, object resolution, and type checking are
// all the engine's own code. Values are read out BEFORE any stack
// reshaping.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Frame::Modern {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// ---- SetSize / GetSize (Region base — all region types) -------------------
// `region:SetSize(width, height)` → SetWidth + SetHeight.
int __fastcall Script_SetSize(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: region:SetSize(width, height)");
return 0;
}
const double h = Game::Lua::ToNumber(L, 3);
Game::Lua::SetTop(L, 2); // (self, width)
CallScript(Offsets::FUN_SCRIPT_REGION_SETWIDTH, L);
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, h);
CallScript(Offsets::FUN_SCRIPT_REGION_SETHEIGHT, L);
return 0;
}
// `region:GetSize()` → width, height.
int __fastcall Script_GetSize(void *L) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(Offsets::FUN_SCRIPT_REGION_GETWIDTH, L); // (self, w)
CallScript(Offsets::FUN_SCRIPT_REGION_GETHEIGHT, L); // (self, w, h)
return 2;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
// matching modern semantics.
template <uintptr_t ShowFn, uintptr_t HideFn>
int __fastcall Script_SetShown(void *L) {
const bool shown = Game::Lua::ToBoolean(L, 2) != 0;
Game::Lua::SetTop(L, 1); // (self) — Show/Hide take no args
CallScript(shown ? ShowFn : HideFn, L);
return 0;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
// modern rename of the vanilla SetMinResize/SetMaxResize pair.
int __fastcall Script_SetResizeBounds(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: frame:SetResizeBounds(minWidth, "
"minHeight [, maxWidth, maxHeight])");
return 0;
}
const bool haveMax = Game::Lua::IsNumber(L, 4) && Game::Lua::IsNumber(L, 5);
const double maxW = haveMax ? Game::Lua::ToNumber(L, 4) : 0.0;
const double maxH = haveMax ? Game::Lua::ToNumber(L, 5) : 0.0;
Game::Lua::SetTop(L, 3); // (self, minW, minH)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMINRESIZE, L);
if (haveMax) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, maxW);
Game::Lua::PushNumber(L, maxH);
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMAXRESIZE, L);
}
return 0;
}
// ---- HookScript (Frame) ----------------------------------------------------
// `frame:HookScript(scriptType, handler)` — vanilla has no HookScript at
// all. Chains onto any existing handler: the old handler runs first,
// vanilla-style (no args — it reads the `this`/`event`/`argN` globals like
// every 1.12 handler), then `handler` is invoked MODERN-style with
// positional arguments `(frame, event, arg1..arg9)` for OnEvent and
// `(frame, arg1..arg9)` otherwise — mirroring the compat convention
// modern addon ports expect (and ship themselves when they detect a
// legacy client). The chain is a C closure over (handler, old, isOnEvent).
// Pushes _G[name].
void PushGlobal(void *L, const char *name) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
}
// The chained handler the engine actually invokes (with no args, like any
// vanilla script handler). Upvalues: 1 = hook handler, 2 = old handler
// (or nil), 3 = isOnEvent (number). Errors inside lua_call propagate to
// the engine's own protected script dispatch.
int __fastcall HookThunk(void *L) {
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(2)) ==
Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
Game::Lua::Call(L, 0, 0);
}
const bool isOnEvent =
Game::Lua::ToNumber(L, Game::Lua::UpvalueIndex(3)) != 0;
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
PushGlobal(L, "this");
if (isOnEvent)
PushGlobal(L, "event");
static const char *const kArgs[] = {"arg1", "arg2", "arg3", "arg4",
"arg5", "arg6", "arg7", "arg8",
"arg9"};
for (const char *a : kArgs)
PushGlobal(L, a);
Game::Lua::Call(L, (isOnEvent ? 2 : 1) + 9, 0);
return 0;
}
int __fastcall Script_HookScript(void *L) {
if (!Game::Lua::IsString(L, 2) ||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: frame:HookScript(\"type\", function)");
return 0;
}
const char *scriptName = Game::Lua::ToString(L, 2);
const bool isOnEvent =
scriptName != nullptr && std::strcmp(scriptName, "OnEvent") == 0;
Game::Lua::SetTop(L, 3); // (self, name, handler)
// GetScript reads self/name at 1/2 and pushes the current handler
// (or nil) — extra stack entries above are ignored by its reads.
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (.., old)
// Closure upvalues, in upvalue order: handler, old, isOnEvent.
Game::Lua::PushValue(L, 3); // handler
Game::Lua::PushValue(L, 4); // old
Game::Lua::PushNumber(L, isOnEvent ? 1.0 : 0.0); // flag
Game::Lua::PushCClosure(L, &HookThunk, 3); // (self,name,handler,old,closure)
Game::Lua::Remove(L, 4); // drop old
Game::Lua::Remove(L, 3); // drop handler → (self,name,closure)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
return 0;
}
// ---- IsEventRegistered (Frame) ---------------------------------------------
// `frame:IsEventRegistered("event")` — vanilla has RegisterEvent /
// UnregisterEvent but not the query. There's no engine Script_* to
// delegate to, so we replicate the membership check that lives inside
// Frame::RegisterEvent (FUN_00702140): find the event by name in the live
// event table, then walk that entry's subscriber chain for this frame.
// The frame pointer we compare against is the same CFrameScriptObject*
// the engine stores (Game::Lua::ResolveObject resolves self exactly as
// Script_RegisterEvent does).
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
bool FrameHasEvent(void *frame, const char *eventName) {
const uint32_t count = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return false;
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(
static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
for (uint32_t i = 0; i < count; ++i) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
continue;
// Names are unique, so this is the one entry — walk its chain.
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
while ((node & 1) == 0 && node != 0) {
if (*reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
return false;
}
int __fastcall Script_IsEventRegistered(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
const char *eventName = Game::Lua::ToString(L, 2);
const bool registered =
frame != nullptr && eventName != nullptr && FrameHasEvent(frame, eventName);
Game::Lua::PushBool(L, registered);
return 1;
}
// ---- SetPoint one-arg form (Script_SetPoint co-hook) -----------------------
// Vanilla's Script_SetPoint accepts every modern form except the bare
// one-arg call (`region:SetPoint("RIGHT")`) — a fully-omitted arg 3 is
// LUA_TNONE, which fails its string/table/number/nil validation and
// raises the usage error. Normalize to the vanilla (point, x, y) form
// with zero offsets, which anchors relative to the parent at the same
// point — exactly the modern one-arg semantic. All other call shapes
// pass through untouched.
ScriptFn_t g_origSetPoint = nullptr;
int __fastcall SetPoint_h(void *L) {
if (Game::Lua::GetTop(L) == 2 && Game::Lua::IsString(L, 2)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushNumber(L, 0.0);
}
return g_origSetPoint(L);
}
const Game::HookAutoRegister _setPointHook{
Offsets::FUN_SCRIPT_REGION_SETPOINT,
reinterpret_cast<void *>(&SetPoint_h),
reinterpret_cast<void **>(&g_origSetPoint)};
// ---- Registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FRAME_SHOW,
Offsets::FUN_SCRIPT_FRAME_HIDE>},
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_TEXTURE_SHOW,
Offsets::FUN_SCRIPT_TEXTURE_HIDE>},
};
const Game::Lua::FrameMethodEntry g_fontStringMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FONTSTRING_SHOW,
Offsets::FUN_SCRIPT_FONTSTRING_HIDE>},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY),
g_regionMethods,
static_cast<int>(sizeof(g_regionMethods) / sizeof(g_regionMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY),
g_textureMethods,
static_cast<int>(sizeof(g_textureMethods) / sizeof(g_textureMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY),
g_fontStringMethods,
static_cast<int>(sizeof(g_fontStringMethods) /
sizeof(g_fontStringMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::Modern
+159 -11
View File
@@ -22,6 +22,24 @@
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
// We read the same shape the engine's own `Script_GetGossip*` reads.
//
// Vanilla has a SECOND questgiver path this namespace also covers:
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
// Quests" / "Available Quests") — whose data lands in a storage
// entirely separate from the gossip arrays (see the VAR_GREETING_*
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
// backporting it's far more useful for `C_GossipInfo` to serve
// whichever questgiver session is actually live:
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
// - greeting session live (VAR_QUESTGIVER_GUID != 0
// and == VAR_GREETING_NPC_GUID) → greeting arrays
// - neither → empty / ""
// The GUID gates double as staleness protection: both storages persist
// after their frames close (the engine never clears them until the
// next open), so ungated reads would report the previous NPC.
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
// options.
//
// Missing modern fields — vanilla 1.12's server simply never sends
// them, so there's nothing to surface:
// - `rewards`, `spellID` (post-vanilla; merged in with the
@@ -46,6 +64,57 @@ namespace {
using EngineScriptFn = int(__fastcall *)(void *L);
uint64_t ReadU64(uintptr_t va) {
return *reinterpret_cast<const uint64_t *>(va);
}
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
bool GossipSessionActive() {
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
}
// A quest-greeting session is live while the quest-session GUID is set
// AND the greeting arrays were filled for that same NPC. The second
// check matters because a gossip-menu quest click opens a quest session
// at panel 1 without ever touching the greeting arrays — without it,
// leftovers from an older greeting NPC would leak through.
bool GreetingSessionActive() {
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
}
const uint8_t *GreetingEntry(bool active, int slot) {
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
return reinterpret_cast<const uint8_t *>(
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
}
int GreetingCount(bool active) {
const int count = *reinterpret_cast<const int *>(
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
return (count < 0) ? 0
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
: count;
}
// The greeting arrays drop the per-quest dialog status, but the packet
// handler's scratch arrays (packet order, zeroed per packet) keep it —
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
bool GreetingQuestIsComplete(uint32_t questID) {
const auto *ids = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_IDS);
const auto *status = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_STATUS);
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
if (ids[i] == questID)
return status[i] == 4;
}
return false;
}
const uint8_t *OptionEntry(int slot) {
return reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
return status == 3 || status == 4;
}
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
// `C_GossipInfo.GetText()` — the greeting string of whichever
// questgiver session is live: the gossip buffer (what
// `Script_GetGossipText` pushes) for a gossip session, the quest
// greeting buffer (what `Script_GetGreetingText` pushes) for a
// greeting session, "" when neither is open. Both buffers persist
// after close, so the gates keep stale text from the previous NPC
// out.
int __fastcall Script_C_GossipInfo_GetText(void *L) {
const char *text = reinterpret_cast<const char *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
Game::Lua::PushString(L, text);
uintptr_t buf = 0;
if (GossipSessionActive())
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
else if (GreetingSessionActive())
buf = Offsets::VAR_QUEST_GREETING_TEXT;
Game::Lua::PushString(
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
return 1;
}
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive())
return 1; // greeting sessions have no options; stale data stays hidden
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
return 1;
}
// Greeting-session variant: walks the pre-split greeting array for the
// wanted side. Same table shape as the gossip rows (questID / title /
// questLevel / isComplete) so consumers can't tell the sources apart.
int PushGreetingQuestList(void *L, bool wantActive) {
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *entry = GreetingEntry(wantActive, slot);
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_GREETING_QUEST_ID);
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID",
static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
entry + Offsets::OFF_GREETING_QUEST_TITLE));
const int32_t level = *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
Game::Lua::SetFieldNumber(L, "questLevel",
static_cast<double>(level));
if (wantActive)
Game::Lua::SetFieldBool(L, "isComplete",
GreetingQuestIsComplete(questID));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
// the same backing array and split by the `status` field — `wantActive`
// inverts the filter.
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive()) {
if (GreetingSessionActive())
return PushGreetingQuestList(L, wantActive);
return 1; // no questgiver session — empty table, never stale data
}
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
if (GossipSessionActive()) {
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
}
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int CountQuests(bool wantActive) {
if (!GossipSessionActive())
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
fn(idx0Based);
}
// Greeting-session selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call. Index straight into the
// corresponding greeting array (they validate the questgiver GUID
// internally before sending the CMSG).
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
auto fn = reinterpret_cast<SelectQuest_t>(
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
fn(idx0Based);
}
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
// passed through to the engine helper which gates on the
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *password =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (!GossipSessionActive())
return 0; // stale option slots must not send a CMSG
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
const uint8_t *entry = OptionEntry(slot);
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
return 0;
}
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
!GossipSessionActive())
return 0;
EngineSelectOption(slot0Based, nullptr);
return 0;
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
}
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
if (!GossipSessionActive()) {
if (!GreetingSessionActive())
return 0;
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
GreetingEntry(wantActive, slot) +
Offsets::OFF_GREETING_QUEST_ID);
if (questID == target) {
EngineSelectGreetingQuest(wantActive, slot);
return 0;
}
}
return 0;
}
int idx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
+82
View File
@@ -24,6 +24,26 @@
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// `GetMouseButtonClicked()` — the button name of the most recent
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
// when no click is being handled. Modern addons call it inside a
// mouse handler to learn which button drove it; the value is evicted
// shortly after, so — like the real API — it reads nil outside a
// handler rather than returning a stale last-click forever.
//
// Modern WoW backs this with a frame-manager field set right before a
// mouse handler runs and cleared right after (verified in 3.3.5:
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
// field is null). We can't bracket the handler the same way here: the
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
// dispatches the frame-script mouse handlers *deferred* (during the
// frame update, not the message pump). So instead we capture the
// button on the message and evict it from the per-frame `WorldTick`
// once a couple of quiet ticks pass with no button held — the value
// survives the whole frame its click lands in (so every handler,
// including multiple addons hooking one `OnClick`, sees it) then
// clears. A held button keeps it set so `OnDragStart` still reads it.
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -33,6 +53,7 @@
#include "Game.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
#include <cstring>
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
// Name of the button from the most recent mouse-button message, or
// nullptr when no click is being handled. Points at one of
// DecodeButton's string literals (stable for the process lifetime),
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
// caller with no extra branch. Set on each button message; evicted by
// EvictTick (see the header note on GetMouseButtonClicked).
const char *g_lastButton = nullptr;
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
// tell a just-clicked frame from a quiet one. Number of consecutive
// quiet ticks (no activity, nothing held) required before eviction —
// >1 so a handler dispatched a frame after its message still reads
// the button.
bool g_buttonActivity = false;
int g_quietTicks = 0;
constexpr int kQuietTicksToEvict = 2;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
if (name == nullptr)
return;
// Capture for GetMouseButtonClicked on both press and release —
// during an OnClick / OnMouseUp handler the "clicked" button is the
// one just released, same as the press for a normal click.
g_lastButton = name;
g_buttonActivity = true;
g_quietTicks = 0;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
return 1;
}
int __fastcall Script_GetMouseButtonClicked(void *L) {
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
// callers outside a mouse handler see nil, matching the modern API.
Game::Lua::PushString(L, g_lastButton);
return 1;
}
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
// world tick. While a button is held we keep the value (drag handlers
// still read it) and reset the quiet counter. A frame that saw a fresh
// button message consumes its activity flag but doesn't yet evict —
// that one-frame grace lets a handler dispatched the frame after its
// message still read the button. Only after kQuietTicksToEvict ticks
// with nothing held and no activity do we clear, so outside a click
// GetMouseButtonClicked reads nil instead of a stale last-click.
void EvictTick() {
if (g_lastButton == nullptr)
return;
if (g_mouseButtonMask != 0) {
g_quietTicks = 0;
return;
}
if (g_buttonActivity) {
g_buttonActivity = false;
g_quietTicks = 0;
return;
}
if (++g_quietTicks >= kQuietTicksToEvict) {
g_lastButton = nullptr;
g_quietTicks = 0;
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -157,11 +236,14 @@ void InstallHook() {
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
&Script_GetMouseButtonClicked);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
+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,
+95
View File
@@ -0,0 +1,95 @@
// 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_Item.IsConsumableItem(item)` — true iff the item is a consumable:
// item class `Consumable` (0), or an inventory type of `INVTYPE_AMMO`
// (24) / `INVTYPE_THROWN` (25) — ammo and thrown weapons being consumed
// in use. `item` is an itemID, `"item:NNN"` string, or item link (names
// aren't resolvable in vanilla → false).
//
// Definition established by decoding 3.3.5's `Script_IsConsumableItem`
// (`0x005173E0`) and then verifying the surviving contract against a
// modern client with `/dump`: it is NOT 3.3.5's on-use-spell-effect walk
// (an on-use trinket like Talisman of Ephemeral Power returns *false*),
// but a plain class-or-ammo/thrown check. Confirmed true: Minor Healing
// Potion / Linen Bandage / Faintly Glowing Skull (all class 0), Rough
// Arrow (Ammo). Confirmed false: Hearthstone (class Misc, valueless
// teleport), on-use trinkets, plain armor.
//
// Cache-miss handling mirrors `C_Item.IsEquippableItem`: returns false
// without firing a load (sync call, matches modern semantics). Callers
// that want auto-warm behavior should run `C_Item.RequestLoadItemDataByID`
// first and re-check on `ITEM_DATA_LOAD_RESULT`.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include <cstdint>
namespace Item::Consumable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
constexpr uint32_t kItemClassConsumable = 0;
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
int __fastcall Script_C_Item_IsConsumableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const bool consumable = classID == kItemClassConsumable ||
invType == kInvTypeAmmo || invType == kInvTypeThrown;
Game::Lua::PushBoolean(L, consumable);
return 1;
}
void RegisterLuaFunctions() {
// Modern exposes both the namespaced `C_Item.IsConsumableItem` and the
// bare global `IsConsumableItem` (the older, still-live form); register
// both against the same handler, like `IsUsableItem`.
Game::Lua::RegisterTableFunction("C_Item", "IsConsumableItem",
&Script_C_Item_IsConsumableItem);
Game::Lua::RegisterGlobalFunction("IsConsumableItem",
&Script_C_Item_IsConsumableItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Consumable
+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);
+91
View File
@@ -0,0 +1,91 @@
// 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 "item/Cursor.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Cursor {
namespace {
using PickupInventorySlot_t = void(__fastcall *)(uint32_t slot0Based);
using CursorPickupItem_t = void(__fastcall *)(uint32_t linearSlot, int count,
uint32_t itemGuidLo, uint32_t itemGuidHi,
uint32_t containerGuidLo,
uint32_t containerGuidHi, int flag);
using LockByGuid_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi);
// The container object stores its own GUID inline at +0x08/+0x0C (unlike a
// CGItem, whose GUID sits behind the +0x08 instance-block pointer). This
// is the "source container" the engine records for the eventual drop.
constexpr int OFF_CONTAINER_GUID_LO = 0x08;
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
bool ItemLocked(const uint8_t *item) {
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
}
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
// block pointer is absent.
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
} // namespace
void PickupEquipmentSlot(int equipmentSlotIndex) {
// The primitive takes a 0-based slot; modern equipmentSlotIndex is 1-based.
reinterpret_cast<PickupInventorySlot_t>(Offsets::FUN_PICKUP_INVENTORY_SLOT)(
static_cast<uint32_t>(equipmentSlotIndex - 1));
}
bool PickupBagItem(void *L, int bagID, int slotIndex) {
Item::Location::BagSlotDetail d;
if (!Item::Location::ResolveBagSlotDetail(L, bagID, slotIndex, &d) || d.item == nullptr)
return false;
// Guards the Script_ wrapper applies that the raw primitive omits: only
// pick up onto an empty cursor, and never a locked (mid-transaction) item.
// `::Cursor` — unqualified `Cursor` would bind to `Item::Cursor` here.
if (::Cursor::Info::HasItem() || ItemLocked(d.item))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadItemGuid(d.item, &itemLo, &itemHi))
return false;
const uint32_t containerLo =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_LO);
const uint32_t containerHi =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_HI);
reinterpret_cast<CursorPickupItem_t>(Offsets::FUN_CURSOR_PICKUP_ITEM)(
static_cast<uint32_t>(d.linearSlot), 1, itemLo, itemHi, containerLo, containerHi, 0);
// Visually lock the source item while it's held — the Script pickup
// does this immediately after the cursor primitive.
reinterpret_cast<LockByGuid_t>(Offsets::FUN_ITEM_LOCK_BY_GUID)(itemLo, itemHi);
return true;
}
} // namespace Item::Cursor
+35
View File
@@ -0,0 +1,35 @@
// 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
// Cursor pickup primitives — thin C++ wrappers over the engine's own
// pickup functions (`FUN_PICKUP_INVENTORY_SLOT` / `FUN_CURSOR_PICKUP_ITEM`),
// called directly rather than through the Lua `Script_Pickup*` state-machine
// wrappers (which would require faking the Lua stack). Shared by
// `C_Item.PickupItem` and `C_Item.EquipItemByName`'s auto-slot path.
namespace Item::Cursor {
// Picks the item in a 1-based character-pane equipment slot onto the
// cursor via the engine's self-contained equipment-pickup primitive
// (which resolves the player and applies its own cursor/lock guards).
void PickupEquipmentSlot(int equipmentSlotIndex);
// Picks the item in a bag slot onto the cursor. Guarded to match the Lua
// wrapper's behavior: no-op (returns false) when the slot is empty, the
// cursor already holds an item, or the item is locked (mid-transaction).
// Returns true when the pickup was issued. Needs `L` for slot resolution
// (PackBagSlot); stomps the Lua stack.
bool PickupBagItem(void *L, int bagID, int slotIndex);
} // namespace Item::Cursor
+15 -36
View File
@@ -14,12 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
#include <string.h>
namespace Item::Equipment {
@@ -132,31 +132,13 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Equipment-first walk; itemID or decorated-name match via the shared
// `Item::Location::MatchesArg` predicate (same logic the bag find-by-
// name uses, so match semantics live in one place).
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = Item::Location::ResolveEquipmentSlot(slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
continue;
}
// Name-match path — peek the item-cache record and compare
// `m_name[0]`. Uncached items short-circuit to "no match"
// rather than firing a load; matches modern API semantics
// (sync call, possibly stale, never blocks).
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
if (Item::Location::MatchesArg(item, arg)) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
@@ -200,8 +182,6 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
const bool hasDstSlot = Game::Lua::IsNumber(L, 2);
const int dstSlot = hasDstSlot ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
using ScriptFn_t = int(__fastcall *)(void *L);
// Return any held cursor item to its slot BEFORE searching for
// the target. FUN_INVENTORY_SWAP's end-of-call cleanup at
// FUN_00495190(0, 1) clears LOCAL cursor globals but skips the
@@ -222,17 +202,16 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
return 0;
}
// Auto-slot path: pickup the item via Script_PickupContainerItem
// (its state-machine handles spell-cast targeting / repair /
// enchant-scroll / etc., none of which we want to replicate), then
// call the engine's `CGPlayer::AutoEquipCursorItem` helper directly
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
// Auto-slot path: pick the item up onto the cursor, then call the
// engine's `CGPlayer::AutoEquipCursorItem` helper directly
// (`Script_AutoEquipCursorItem` is a thin wrapper that just resolves
// the local player and calls this with flag=0). The pickup goes through
// the shared `Item::Cursor::PickupBagItem`, which drives the engine's
// cursor primitive directly (no Script_PickupContainerItem stack-fake);
// its empty-cursor guard is satisfied by the ClearCursor above. Bail if
// the pickup didn't take (locked item / busy cursor) — nothing to equip.
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
+74
View File
@@ -0,0 +1,74 @@
// 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_Item.IsItemInRange(item, targetUnit)` — true if the item is in
// range of the unit, false if out of range, nil when the range check
// doesn't apply. `item` is an itemID, `"item:NNN"` string, or item link
// (names aren't resolvable — vanilla has no name→ID map — and yield nil);
// `targetUnit` is a unit token ("target", "mouseover", "party1", …).
//
// An item's range comes from the spell it fires on use: we resolve the
// item to its on-use spell (`Item::Spell::OnUseSpellIDForItemID`) and run
// the same range test as `C_Spell.IsSpellInRange`, via the shared
// `Spell::Range::PlayerVsUnit` core. So items with a targeted on-use
// effect that has a range (nets, bombs, thrown/targeted quest items, …)
// get a true/false answer; items with no on-use spell, or whose on-use
// spell is rangeless (potions, food, self-buff trinkets, hearthstone),
// return nil — matching retail, which also reports nil for items without
// a range restriction.
//
// Range-only, like `IsSpellInRange`: ignores line of sight and target
// faction. Passive reader — an item not yet in the client item cache
// returns nil (no background fetch); items you'd range-check are normally
// in bags / on the action bar and already cached.
#include "Game.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Range.h"
#include <cstdint>
namespace Item::InRange {
namespace {
int __fastcall Script_C_Item_IsItemInRange(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const int spellID =
itemID > 0 ? Item::Spell::OnUseSpellIDForItemID(
static_cast<uint32_t>(itemID))
: 0;
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// `::Spell` — unqualified `Spell` would bind to `Item::Spell` here.
const int result = ::Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemInRange",
&Script_C_Item_IsItemInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::InRange
+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);
+13
View File
@@ -27,6 +27,9 @@ namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -50,6 +53,16 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
if (cgItem == nullptr || out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildInstanceName_t>(
Offsets::FUN_ITEM_BUILD_INSTANCE_NAME);
fn(cgItem, out, static_cast<unsigned>(outSize));
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
+11
View File
@@ -29,6 +29,17 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Writes the item's per-instance decorated *display name* into `out` —
// the same `[Name]` the engine stamps into `FromCGItem`'s link, e.g.
// "Iridium Chain of the Owl" (or the base name when the instance carries
// no random suffix). Reads the suffix ID off the CGItem descriptor
// (`+0x98`) and formats it through the engine's own name builder
// (`FUN_ITEM_BUILD_INSTANCE_NAME`). Returns false (and leaves `out`
// empty) for a null item or an empty build result, so callers can fall
// back to the base ItemStats name. Use whenever a name is returned
// alongside a `FromCGItem` link, so the two agree.
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
+102 -75
View File
@@ -16,8 +16,10 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../unit/Identity.h"
#include "Arg.h"
#include "ID.h"
#include "Link.h"
#include <cstring>
@@ -28,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.
@@ -56,24 +48,10 @@ bool TryReadIntField(void *L, int locIdx, const char *fieldName, int *out) {
}
const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *invMgr = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &invMgr, &linearSlot, &unused) || invMgr == nullptr)
BagSlotDetail d;
if (!ResolveBagSlotDetail(L, bagID, slotIndex, &d))
return nullptr;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, linearSlot));
return d.item;
}
// --- GUID-walk helpers ---------------------------------------------------
@@ -97,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
@@ -108,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.
@@ -129,6 +135,30 @@ int GetBagSlotCount(int bagID) {
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
}
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *container = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &container, &linearSlot, &unused) || container == nullptr)
return false;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
out->container = static_cast<const uint8_t *>(container);
out->linearSlot = linearSlot;
out->item = static_cast<const uint8_t *>(GetItemBySlot(container, linearSlot));
return true;
}
const uint8_t *ResolveBag(void *L, int bagID, int slotIndex) {
return ResolveBagSlot(L, bagID, slotIndex);
}
@@ -152,38 +182,29 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
Item::Arg::Resolved arg{};
arg.itemID = itemID;
arg.name = nullptr;
return FindByArg(L, arg, out);
}
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
return false;
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg) {
const int id = Item::ID::FromCGItem(cgItem);
if (id == 0)
return false;
if (arg.itemID > 0)
return id == arg.itemID;
if (arg.name == nullptr)
return false;
// Compare against the item's *decorated* display name (random suffix
// included) — modern IsEquippedItem / find-by-name match the full name
// only ("Foo of the Owl", not the base "Foo"; verified on the modern
// client). NameFromCGItem falls back to the base name for unsuffixed
// items, so plain items still match; uncached/unnameable → no match.
char name[128];
if (!Item::Link::NameFromCGItem(cgItem, name, sizeof(name)))
return false;
return _stricmp(name, arg.name) == 0;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
@@ -196,24 +217,9 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
if (!MatchesArg(item, arg))
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
@@ -224,6 +230,27 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
return false;
}
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
// Equipment slots first (no Lua stack interaction, cheaper), matching
// `FindByItemID`'s equipment-before-bags order; then fall through to the
// bag walk. Both compare with the shared `MatchesArg` predicate.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item != nullptr && MatchesArg(item, arg)) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
return FindByArgInBags(L, arg, out);
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+58 -3
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
@@ -96,6 +118,22 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
// Full detail for a bag slot, for callers that drive the engine's
// cursor-pickup primitive — which needs the *container object* and the
// engine *linear slot*, not just the CGItem.
struct BagSlotDetail {
const uint8_t *container; // PackBagSlot container (invMgr for the backpack, else the bag)
int linearSlot; // engine linear inventory slot
const uint8_t *item; // CGItem at the slot, or null if the slot is empty
};
// Resolves `(bagID, slotIndex)` to its `BagSlotDetail`. Returns false when
// the coordinates don't resolve (bad bag / out-of-range slot); a resolved
// but empty slot returns true with `out->item == nullptr`. Stomps the Lua
// stack (PackBagSlot). `ResolveBag`/`ResolveBagSlot` are thin wrappers that
// keep only `out->item`.
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
@@ -103,10 +141,19 @@ namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// The single item name/id match predicate: true if `cgItem` satisfies
// the resolved arg. `arg.itemID > 0` ⇒ exact itemID compare; otherwise
// `arg.name` is matched case-insensitively against the item's *decorated*
// display name (random suffix included — via `Item::Link::NameFromCGItem`,
// which falls back to the base name for unsuffixed items). Matches modern
// `IsEquippedItem` / by-name semantics: "Foo of the Owl" matches, the base
// "Foo" does not. Null / uncached / unnameable items → false. Shared by
// `FindByArgInBags` and `C_Item.IsEquippedItem` so the match logic lives
// in exactly one place.
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg);
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// first item matching `arg` (via `MatchesArg`). On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
@@ -116,6 +163,14 @@ namespace Item::Location {
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Like `FindByArgInBags` but searches **equipment (1..19) first, then
// bags** — for callers that act on an item wherever it's carried (e.g.
// `C_Item.PickupItem`). On hit `out->equipmentSlotIndex` is non-zero for
// an equipped match, else `out->bagID`/`out->slotIndex` for a bag match.
// `FindByItemID` is the itemID-only specialization of this. Stomps the
// Lua stack.
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+25 -5
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -56,18 +57,37 @@ int PushNameForItemID(void *L, int itemID) {
return 1;
}
// Pushes the suffix-decorated display name for a resolved `CGItem` via
// the shared engine name builder. Returns 0 (pushes nothing) for a null
// item or an empty build result, so the caller can fall back to the
// base-name-by-itemID path.
int PushInstanceName(void *L, const uint8_t *cgItem) {
char buf[128];
if (!Item::Link::NameFromCGItem(cgItem, buf, sizeof(buf)))
return 0;
Game::Lua::PushString(L, buf);
return 1;
}
} // namespace
// `C_Item.GetItemName(itemLocation)` — returns the cached display name
// of the item at the location, or nil for empty / uncached / invalid
// inputs. Reads `m_name[0]` straight off `ItemStats_C` — no engine
// round-trip, no `GetItemInfo` chaining.
// `C_Item.GetItemName(itemLocation)` — returns the display name of the
// item at the location, or nil for empty / uncached / invalid inputs.
// The location points at a real item instance, so the name carries any
// random-suffix decoration ("... of the Sorcerer") — built off the
// CGItem via the engine's own name builder, matching modern WoW and
// `C_Item.GetItemLink`'s bracketed name. Falls back to the base
// `ItemStats_C` name (and warms the cache) if the instance build yields
// nothing.
static int __fastcall Script_C_Item_GetItemName(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemName(itemLocation)");
return 0;
}
return PushNameForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
const uint8_t *cgItem = Item::Location::Resolve(L, 1);
if (int pushed = PushInstanceName(L, cgItem))
return pushed;
return PushNameForItemID(L, Item::ID::FromCGItem(cgItem));
}
// `C_Item.GetItemNameByID(itemInfo)` — returns the cached display name
+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
+63
View File
@@ -0,0 +1,63 @@
// 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_Item.PickupItem(itemInfo)` — finds the item matching `itemInfo` in
// the player's equipment or bags and picks it up onto the cursor.
// `itemInfo` is an itemID, `"item:NNN"` string, item link, or item name —
// the same input `C_Item.UseItemByName` / `EquipItemByName` accept,
// resolved via `Item::Arg::Resolve` and matched by `FindByArg` (itemID, or
// decorated name for a name arg) across equipment (1..19, checked first)
// and bags (0..4).
//
// The pickup goes through the shared `Item::Cursor` wrappers, which call
// the engine's cursor primitives directly (no Lua `Script_Pickup*`
// wrapper): an equipped match uses the equipment-slot primitive, a bag
// match the bag primitive (guarded on an empty cursor + unlocked item).
// Silently no-ops on bad input, no match, a busy cursor, or a locked item.
#include "Game.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/Location.h"
namespace Item::Pickup {
namespace {
int __fastcall Script_C_Item_PickupItem(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr)
return 0;
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArg(L, arg, &found))
return 0;
// equipmentSlotIndex != 0 → equipped (paperdoll slot); else a bag slot.
if (found.equipmentSlotIndex != 0)
Item::Cursor::PickupEquipmentSlot(found.equipmentSlotIndex);
else
Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "PickupItem",
&Script_C_Item_PickupItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Pickup
+23 -23
View File
@@ -48,29 +48,6 @@ const uint8_t *FetchItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
const char *SpellNameForID(int spellID) {
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
@@ -110,6 +87,29 @@ int __fastcall Script_GetItemSpell(void *L) {
} // namespace
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
int OnUseSpellIDForItemID(uint32_t itemID) {
if (itemID == 0)
return 0;
+8
View File
@@ -29,4 +29,12 @@ namespace Item::Spell {
// the same use-spell still register).
int OnUseSpellIDForItemID(uint32_t itemID);
// Walks a cached `ItemStats_C` record's 5 spell slots and returns the
// first ON_USE (trigger=0) spell ID, or 0 if none. Split out so callers
// that already hold the record (e.g. `Item::Usable`, which also reads the
// record's use requirements) don't peek the cache twice or re-derive the
// slot walk. `OnUseSpellIDForItemID` is the peek-by-itemID convenience
// wrapper over this.
int FindOnUseSpellIDInRecord(const uint8_t *record);
} // namespace Item::Spell
+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;
+13 -18
View File
@@ -220,24 +220,19 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const char *link = Item::Link::FromCGItem(
static_cast<const uint8_t *>(cgItem));
if (link != nullptr && *link != '\0') {
// Engine's link builder also writes the dressed
// (random-suffixed) name into the link's `[Name]`
// slot; pull it out by reading the cached base name
// for the return value. The engine doesn't expose
// the dressed name as a separate string, so we
// return the base name — matches modern semantics
// where (name, link) name is the cached display name
// and the link is the full hyperlink.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record != nullptr) {
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name != nullptr && *name != '\0') {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
// Return the dressed (random-suffixed) name the engine
// wrote into the link's `[Name]` slot — built off the same
// CGItem via Item::Link::NameFromCGItem — so the returned
// name agrees with the link ("Iridium Chain of the Owl",
// not the base "Iridium Chain") and matches modern
// GameTooltip:GetItem() on suffixed items.
char name[128];
if (Item::Link::NameFromCGItem(
static_cast<const uint8_t *>(cgItem), name, sizeof(name))) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
}
}
+180
View File
@@ -0,0 +1,180 @@
// 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/>.
// `IsUsableItem(item)` / `C_Item.IsUsableItem(item)` — is the item's
// *on-use* ability usable by the local player right now, returning
// `(usable, noMana)`. This is the item analog of `IsUsableSpell`; it has
// nothing to do with whether the item can be *equipped* (armor
// proficiency / class-restricted gear is not this function's concern —
// that's the tooltip's red requirement lines, evaluated separately).
//
// Semantics (mirrors 3.3.5's `Script_IsUsableItem` structure, rebuilt on
// 1.12 primitives — vanilla ships no `IsUsableItem` and its item-action
// usability path never computed on-use power/noMana):
// 1. Item must have an on-use spell (a `SPELL_TRIGGER[i] == ON_USE(0)`
// slot with a non-zero `SPELL_ID[i]`). No on-use ability → not
// usable (there's nothing to "use").
// 2. The item's own *use* requirements must be met: RequiredLevel, and
// the AllowableClass / AllowableRace masks. These gate activation
// (e.g. a potion below your level, a class-restricted trinket) and
// are use — not equip — requirements, so they belong here.
// 3. The on-use spell must be castable now: delegated to the engine's
// own spell-castability helper `FUN_SPELL_IS_USABLE`, which folds in
// form/stance, mechanic immunities, aura state, and the power check.
// Insufficient power is the only condition that flips `noMana` (and
// leaves `usable` false), exactly like `IsUsableSpell`.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a sync peek with
// no load fired, returning `(false, false)` if the item isn't cached yet.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Item::Usable {
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);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
// Same item-cache peek pattern as the other item modules (no shared
// helper — each call site is one line different). Sync, no load fired.
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);
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
// 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) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << (index1Based - 1))) == 0;
}
struct Usability {
bool usable;
bool noMana;
};
Usability ComputeUsability(int itemID) {
Usability r{false, false};
if (itemID <= 0)
return r;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return r; // not cached → sync "false", no load fired
const int onUseSpellID = Item::Spell::FindOnUseSpellIDInRecord(record);
if (onUseSpellID == 0)
return r; // no on-use ability → nothing to use
auto *desc = PlayerDescriptor();
if (desc == nullptr)
return r; // pre-login / no player
// Use requirements (NOT equip requirements): level + class/race.
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return r;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return r;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return r;
// On-use spell castability + noMana, from the engine's own helper.
// `::Spell::Lookup` (leading `::`) — inside `Item::Usable`, a bare
// `Spell::` binds to the included `Item::Spell` namespace.
auto *spellRec = ::Spell::Lookup::RecordForID(onUseSpellID);
if (spellRec == nullptr)
return r;
auto isUsable = reinterpret_cast<SpellIsUsable_t>(Offsets::FUN_SPELL_IS_USABLE);
int noMana = 0;
const int usable = isUsable(spellRec, &noMana) & 0xFF;
r.usable = usable != 0;
r.noMana = noMana != 0;
return r;
}
// `IsUsableItem(item)` — legacy global. Returns `(usable, noMana)` as
// 1/nil pairs, matching stock 1.12's `IsUsableAction` / our
// `IsUsableSpell` convention.
int __fastcall Script_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
if (r.usable) {
Game::Lua::PushNumber(L, 1.0);
Game::Lua::PushNil(L);
} else if (r.noMana) {
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, 1.0);
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
}
return 2;
}
// `C_Item.IsUsableItem(item)` — modern table-namespace form. Returns
// proper booleans (`usable`, `noMana`) per the `C_Item.*` convention.
int __fastcall Script_C_Item_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
Game::Lua::PushBool(L, r.usable);
Game::Lua::PushBool(L, r.noMana);
return 2;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsUsableItem", &Script_IsUsableItem);
Game::Lua::RegisterTableFunction("C_Item", "IsUsableItem",
&Script_C_Item_IsUsableItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Item::Usable
+2 -59
View File
@@ -48,7 +48,6 @@
#include "item/Location.h"
#include <cstdint>
#include <cstring>
namespace Item::Use {
@@ -59,67 +58,10 @@ using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targe
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
@@ -130,12 +72,13 @@ int __fastcall Script_C_Item_UseItemByName(void *L) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &targetGuid, 0);
return 0;
+559
View File
@@ -0,0 +1,559 @@
// 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_LootHistory backport.
//
// Modern WoW (2.x+, incl. 5.4.8) exposes a `C_LootHistory` namespace backed
// by an engine-maintained ring buffer that a dedicated loot-history server
// packet fills. Vanilla 1.12 has NO such packet or store — but it DOES
// receive the raw group-loot roll traffic (SMSG_LOOT_ROLL per player,
// SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED), which the default UI only turns
// into chat lines. So we reconstruct the history client-side (à la
// Aura::Source): co-hook the three roll-result handlers and accumulate a ring
// of rolled items + per-player results, then expose C_LootHistory reading
// from it.
//
// The handlers run on the deserialized PendingLootRoll message (0x48 bytes,
// LootRoll.cpp) with the fields at the OFF_LOOTROLL_* offsets. The message is
// populated at handler entry and freed at exit, so each co-hook reads its
// fields BEFORE calling the original.
//
// === Handler ABI (verified from disassembly — RET conventions DIFFER) ===
// ROLL FUN_0061C0B0: __fastcall(msg=ECX, ctx=EDX, int stackArg) -> RET 4
// WON FUN_0061B9E0: __fastcall(ctx=ECX, msg=EDX, int stackArg) -> RET 4
// PASSED FUN_0061B640: __fastcall(ctx=ECX, msg=EDX) -> RET 0
// The trailing stack arg (and matching callee cleanup) is LOAD-BEARING: an
// earlier revision declared these as 2-arg hooks (implicit RET 0), so every
// ROLL/WON call left ESP unbalanced by 4 -> stack corruption -> crash on the
// first loot roll. Each hook below matches its handler's exact shape.
//
// Name + class per roller come from the engine NameCache (read at hook time,
// guaranteed loaded then), so they resolve even for someone who has left the
// group by the time their roll packet arrives. Surface: C_LootHistory
// GetNumItems / GetItem / GetPlayerInfo, plus LOOT_HISTORY_ROLL_CHANGED /
// _ROLL_COMPLETE events. The MoP master-loot management functions
// (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted
// — they depend on server support 1.12 doesn't have.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
namespace Loot::History {
namespace {
template <typename T>
T Read(const void *base, int off) {
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
}
// Engine millisecond clock — same source (and epoch) as Lua's `GetTime()`
// (which reads this scaled by 0.001), so a value stored here is directly
// comparable to `GetTime()` once divided by 1000.
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
constexpr int ROLL_PASS = 0;
constexpr int ROLL_NEED = 1;
constexpr int ROLL_GREED = 2;
// The SMSG_LOOT_ROLL packet encodes rollType as 0 = need, 2 = greed; map to
// the modern convention. A "didn't actually roll" (pass) is flagged by the
// high bit of the roll-number byte, handled by the caller.
int MapRollType(int packetType) {
return packetType == 2 ? ROLL_GREED : ROLL_NEED;
}
// GUID -> engine NameCache entry-data block. This is the same primitive the
// engine's own `%N` name substitution uses (FUN_005083A0): for a LOADED entry
// the lookup returns the data block (name[48] at +0, class at +0x140, …),
// regardless of the (null) callback and regardless of current group
// membership — so it resolves a roller who has since left the group, which
// is exactly what a loot roll needs (the roll packet arrives after they may
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
// gates on this same lookup succeeding. Returns nullptr if not cached.
const uint8_t *LookupPlayerRecord(uint64_t guid) {
if (guid == 0)
return nullptr;
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
uint32_t hi, void *cookie,
void *cb, void *ud, char flag);
auto fn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
uint32_t cookie[2] = {0, 0};
return fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
cookie, nullptr, nullptr, 0);
}
const char *NameFromRecord(const uint8_t *rec) {
return rec != nullptr
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
: nullptr;
}
uint32_t ClassIdFromRecord(const uint8_t *rec) {
return rec != nullptr
? *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_PLAYER_INFO_CLASS)
: 0;
}
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
// the ID is out of range or the record is missing.
const char *ClassTokenForId(uint32_t classId) {
if (classId == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
if (records == nullptr || static_cast<int>(classId) > count)
return nullptr;
const uint8_t *rec = records[classId];
if (rec == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_CHRCLASSES_FILENAME);
}
constexpr int kNameLen = 48; // engine NameCache inline name[48]
void CopyName(char *dst, const char *src) {
if (src == nullptr) {
dst[0] = '\0';
return;
}
int i = 0;
for (; i < kNameLen - 1 && src[i] != '\0'; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
struct PlayerRoll {
uint64_t guid;
char name[kNameLen];
uint32_t classId; // ChrClasses.dbc ID (0 = unknown)
int rollType; // ROLL_* (modern); ROLL_PASS when the player passed
int roll; // 1..100, or 0 if they passed / didn't roll
};
constexpr int kMaxPlayers = 40;
struct HistItem {
uint32_t rollID; // stable monotonic ID — survives ring-index shifts
uint64_t sourceGuid; // looted-object GUID — the roll's identity
uint32_t slot;
uint32_t itemId;
int32_t itemSuffix; // random suffix/property ID (link suffix field)
uint32_t itemSeed; // random seed (link unique field)
uint64_t winnerGuid; // winner's roller GUID (0 if undecided)
bool hasWinner;
bool isDone;
uint32_t createdMs; // engine tick at creation (GetTime()*1000 epoch)
PlayerRoll players[kMaxPlayers];
int playerCount;
bool used;
};
// Ring of recent rolled items. g_write is the next slot to fill; g_count
// saturates at kMaxItems. GetNumItems returns g_count; item index i (1-based,
// oldest→newest) maps to ring[(g_write - g_count + i - 1) mod kMaxItems].
// ~3 KB per item (players[40] dominates), so 128 is ~380 KB static — bump
// freely if a longer history is wanted.
constexpr int kMaxItems = 128;
HistItem g_items[kMaxItems];
int g_write = 0;
int g_count = 0;
uint32_t g_nextRollId = 1; // 0 is reserved as "invalid" (never assigned)
// Set by FindOrCreate for the most recent call, read by the hooks to decide
// whether a structural LOOT_HISTORY_FULL_UPDATE is warranted (a new item
// appeared, or the ring evicted an old one so every index shifted).
bool g_createdNew = false;
bool g_evicted = false;
HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
for (auto &e : g_items) {
if (e.used && e.sourceGuid == sourceGuid && e.slot == slot)
return &e;
}
return nullptr;
}
HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
g_createdNew = false;
g_evicted = false;
HistItem *e = Find(sourceGuid, slot);
if (e != nullptr) {
if (itemId != 0)
e->itemId = itemId;
return e;
}
e = &g_items[g_write];
g_evicted = e->used; // overwriting a live slot => ring full => eviction
g_write = (g_write + 1) % kMaxItems;
if (g_count < kMaxItems)
++g_count;
*e = HistItem{};
e->used = true;
e->rollID = g_nextRollId++;
e->createdMs = NowMs();
e->sourceGuid = sourceGuid;
e->slot = slot;
e->itemId = itemId;
g_createdNew = true;
return e;
}
// Find the player-roll slot for `guid`, appending a fresh one (name/class
// resolved from the NameCache) if absent and there's room. Returns the
// 0-based index, or -1 when the roster is full. Shared by the roll recorder
// and the winner recorder — the winner may never have sent an individual
// SMSG_LOOT_ROLL (joined late / master loot), so ensuring they're present
// lets GetItem's winnerIdx resolve through GetPlayerInfo.
int FindOrAddPlayer(HistItem *e, uint64_t guid) {
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == guid)
return i;
}
if (e->playerCount >= kMaxPlayers)
return -1;
const int idx = e->playerCount++;
const uint8_t *rec = LookupPlayerRecord(guid); // cache loaded at hook time
e->players[idx] = PlayerRoll{};
e->players[idx].guid = guid;
CopyName(e->players[idx].name, NameFromRecord(rec));
e->players[idx].classId = ClassIdFromRecord(rec);
return idx;
}
// 1-based index within players[] of the winner, or 0 if undecided / absent.
int WinnerIndex(const HistItem *e) {
if (!e->hasWinner || e->winnerGuid == 0)
return 0;
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == e->winnerGuid)
return i + 1;
}
return 0;
}
// 1-based access in oldest→newest order (matching GetNumItems' count).
HistItem *GetEntry(int index1Based) {
if (index1Based < 1 || index1Based > g_count)
return nullptr;
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
return &g_items[(start + index1Based - 1) % kMaxItems];
}
// The 1-based index of an entry in oldest→newest order (0 if not live).
int IndexOf(const HistItem *e) {
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
for (int i = 0; i < g_count; ++i) {
if (&g_items[(start + i) % kMaxItems] == e)
return i + 1;
}
return 0;
}
// Modern loot-history events. ROLL_CHANGED(itemIdx, playerIdx) fires on each
// roll; ROLL_COMPLETE(itemIdx) when the item is decided (winner / all-passed).
// FULL_UPDATE is reserved for API/IsEventValid completeness (the UI fires it
// on open); we don't dispatch it from C.
constexpr const char *kRollChanged = "LOOT_HISTORY_ROLL_CHANGED";
constexpr const char *kRollComplete = "LOOT_HISTORY_ROLL_COMPLETE";
constexpr const char *kFullUpdate = "LOOT_HISTORY_FULL_UPDATE";
const Event::Custom::AutoReserve _evRollChanged{kRollChanged};
const Event::Custom::AutoReserve _evRollComplete{kRollComplete};
const Event::Custom::AutoReserve _evFullUpdate{kFullUpdate};
// Returns the 1-based item index and writes the 1-based player index to
// `*outPlayerIdx` (0 if none), so the hook can fire ROLL_CHANGED.
int RecordRoll(const void *msg, int *outPlayerIdx) {
*outPlayerIdx = 0;
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t roller = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
const uint8_t rollByte = Read<uint8_t>(msg, Offsets::OFF_LOOTROLL_ROLL_NUM);
const int packetType = Read<int32_t>(msg, Offsets::OFF_LOOTROLL_ROLL_TYPE);
const bool didRoll = (rollByte & 0x80) == 0;
const int rollNum = didRoll ? (rollByte & 0x7F) : 0;
const int rollType = didRoll ? MapRollType(packetType) : ROLL_PASS;
HistItem *e = FindOrCreate(src, slot, itemId);
if (roller == 0)
return IndexOf(e);
const int slotIdx = FindOrAddPlayer(e, roller);
if (slotIdx >= 0) {
e->players[slotIdx].rollType = rollType;
e->players[slotIdx].roll = rollNum;
*outPlayerIdx = slotIdx + 1;
}
return IndexOf(e);
}
int RecordWinner(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t winner = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->winnerGuid = winner;
e->hasWinner = true;
e->isDone = true;
if (winner != 0)
FindOrAddPlayer(e, winner); // so GetItem's winnerIdx resolves
return IndexOf(e);
}
int RecordAllPassed(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->isDone = true;
return IndexOf(e);
}
// SMSG_LOOT_START_ROLL carries the item's random suffix/seed; the result
// packets don't. It's read from the freshly-built store-head node (rather
// than the packet) right after the START_ROLL handler runs — the node has
// the same field offsets as the message. This is the only place the random
// fields are populated, so the result recorders leave them alone.
void RecordStart() {
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
if (node == nullptr)
return;
const uint64_t src = Read<uint64_t>(node, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->itemSuffix = Read<int32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SUFFIX);
e->itemSeed = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SEED);
}
// ---- Packet co-hooks -------------------------------------------------------
// Signatures match the verified per-handler ABI above (note the differing
// message register, stack arg, and RET). Read the message fields before the
// original runs — it frees the message.
using RollFn_t = void(__fastcall *)(void *msg, void *ctx, int a3); // RET 4
using WonFn_t = void(__fastcall *)(void *ctx, void *msg, int a3); // RET 4
using PassedFn_t = void(__fastcall *)(void *ctx, void *msg); // RET 0
using StartFn_t = void(__fastcall *)(void *packet); // RET 0
RollFn_t g_origRoll = nullptr;
WonFn_t g_origWon = nullptr;
PassedFn_t g_origPassed = nullptr;
StartFn_t g_origStart = nullptr;
// Fire LOOT_HISTORY_FULL_UPDATE (no payload) — the "rebuild everything"
// signal, matching the UI's full refresh. Called when the item set changes
// structurally: a new item appears or the ring evicts an old one (shifting
// every index).
void FireFullUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
}
// START_ROLL: let the original build the store node, then read the item's
// random suffix off it. Adds the item to the history so a UI can show it as
// soon as the roll opens (before any roll comes in) — hence the FULL_UPDATE.
void __fastcall Start_h(void *packet) {
g_origStart(packet);
RecordStart();
if (g_createdNew || g_evicted)
FireFullUpdate();
}
// Record fields (message is freed by the original), run the original, THEN
// fire the event — firing after the original keeps the engine's own handler
// off our re-entrancy (same order Item::Data uses from its packet hook). A
// structural FULL_UPDATE (if any) precedes the granular event so a listener
// that rebuilds on FULL_UPDATE sees the new row before the row-level refresh.
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
int itemIdx = 0, playerIdx = 0;
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordRoll(msg, &playerIdx);
structural = g_createdNew || g_evicted;
}
g_origRoll(msg, ctx, a3);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
playerIdx);
}
void __fastcall Won_h(void *ctx, void *msg, int a3) {
int itemIdx = 0;
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordWinner(msg);
structural = g_createdNew || g_evicted;
}
g_origWon(ctx, msg, a3);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
int itemIdx = 0;
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordAllPassed(msg);
structural = g_createdNew || g_evicted;
}
g_origPassed(ctx, msg);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
const Game::HookAutoRegister _hookRoll{
Offsets::FUN_LOOT_ROLL_HANDLER, reinterpret_cast<void *>(&Roll_h),
reinterpret_cast<void **>(&g_origRoll)};
const Game::HookAutoRegister _hookWon{
Offsets::FUN_LOOT_ROLL_WON_HANDLER, reinterpret_cast<void *>(&Won_h),
reinterpret_cast<void **>(&g_origWon)};
const Game::HookAutoRegister _hookPassed{
Offsets::FUN_LOOT_ALL_PASSED_HANDLER, reinterpret_cast<void *>(&Passed_h),
reinterpret_cast<void **>(&g_origPassed)};
const Game::HookAutoRegister _hookStart{
Offsets::FUN_LOOT_START_ROLL_HANDLER, reinterpret_cast<void *>(&Start_h),
reinterpret_cast<void **>(&g_origStart)};
// ---- Lua surface -----------------------------------------------------------
// `C_LootHistory.GetNumItems()` -> number of rolled items in the history.
int __fastcall Script_GetNumItems(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(g_count));
return 1;
}
// `C_LootHistory.GetItem(itemIndex)` ->
// rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp
// Matches the MoP signature: `rollID` is a stable ID (unlike itemIndex, which
// shifts as the ring saturates — key expanded/highlighted rows on it);
// `itemLink` is the `item:id:enchant:suffix:unique` form (valid for
// GetItemInfo / SetHyperlink), carrying the roll's random suffix so "of the X"
// items keep their affix; `winnerIdx` is the winner's 1-based playerIndex for
// GetPlayerInfo(itemIndex, winnerIdx) (nil if undecided). `timestamp` is a
// ClassicAPI extension: the `GetTime()`-comparable creation time in seconds.
int __fastcall Script_GetItem(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItem(itemIndex)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
static char link[64];
// Vanilla item link: item:id:enchant:suffix:unique. Carry the roll's
// random suffix/seed so "of the Bear"-style items keep their affix.
std::snprintf(link, sizeof(link), "item:%u:0:%d:%u", e->itemId,
e->itemSuffix, e->itemSeed);
Game::Lua::PushNumber(L, static_cast<double>(e->rollID)); // 1 rollID
Game::Lua::PushString(L, link); // 2 itemLink
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 3 numPlayers
Game::Lua::PushBool(L, e->isDone); // 4 isDone
const int winnerIdx = WinnerIndex(e);
if (winnerIdx > 0)
Game::Lua::PushNumber(L, static_cast<double>(winnerIdx)); // 5 winnerIdx
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, e->createdMs / 1000.0); // 6 timestamp
return 6;
}
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
// name, classToken, rollType, roll, isWinner, isMe. classToken is the
// ChrClasses filename ("WARRIOR", …) for RAID_CLASS_COLORS-style coloring;
// both name and class come from the engine NameCache captured at roll time.
// isMe compares the roller GUID to the local player's.
int __fastcall Script_GetPlayerInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GetPlayerInfo(itemIndex, playerIndex)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
const int p = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
if (p < 0 || p >= e->playerCount)
return 0;
const PlayerRoll &r = e->players[p];
if (r.name[0] != '\0')
Game::Lua::PushString(L, r.name); // 1 name
else
Game::Lua::PushNil(L);
const char *classToken = ClassTokenForId(r.classId); // 2 class token
if (classToken != nullptr)
Game::Lua::PushString(L, classToken);
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(r.rollType)); // 3 rollType
Game::Lua::PushNumber(L, static_cast<double>(r.roll)); // 4 roll
Game::Lua::PushBool(L, e->hasWinner && e->winnerGuid == r.guid); // 5 isWinner
Game::Lua::PushBool(L, r.guid == Unit::Identity::PlayerGuid()); // 6 isMe
return 6;
}
// `C_LootHistory.Clear()` -> wipe the accumulated history. A ClassicAPI
// extension (no MoP equivalent) so a UI can offer a "clear history" button.
// Fires LOOT_HISTORY_FULL_UPDATE so any open display rebuilds empty.
int __fastcall Script_Clear(void *L) {
(void)L;
for (auto &e : g_items)
e.used = false;
g_write = 0;
g_count = 0;
FireFullUpdate();
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_LootHistory", "GetNumItems",
&Script_GetNumItems);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetItem",
&Script_GetItem);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
&Script_GetPlayerInfo);
Game::Lua::RegisterTableFunction("C_LootHistory", "Clear", &Script_Clear);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Loot::History
+373
View File
@@ -0,0 +1,373 @@
// 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_LossOfControl` — active loss-of-control effects on the LOCAL PLAYER.
// Vanilla 1.12 has no such aggregation layer (it's a MoP addition), so we
// synthesize it from two sources:
//
// 1. SCHOOL_INTERRUPT — the Counterspell/Kick school lockout. The server
// (Player::ProhibitSpellSchool) delivers it as a single
// SMSG_SPELL_COOLDOWN carrying every spell of the interrupted school,
// all with the same duration. That "player-GUID packet, >=2 spells, one
// uniform duration" shape is produced by nothing else (normal cooldowns
// are single-spell or applied client-side), so co-hooking the cooldown
// handler and matching it yields an authoritative school + duration.
// The interrupting spell isn't in the packet, so spellID is 0 and
// displayText is a fixed "Interrupted" — the mechanically-relevant data
// (school, duration, timing) is exact.
//
// 2. CC debuffs — stun / fear / root / confuse / charm / possess / silence /
// pacify / disarm are real debuff auras. We scan the player's debuffs and
// classify each by its spell's EffectApplyAuraName. Timing is best-effort
// from the Aura::Source cache (nil when the cast wasn't observed — the
// LoC timing fields are nullable, so that's contract-valid).
//
// Placeholders for fields with no vanilla equivalent: priority = 0,
// displayType = 2 (show for full duration), auraInstanceID = nil. iconTexture
// is the spell's icon PATH (vanilla has no FileDataIDs; a path is the
// functional equivalent).
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace LossOfControl {
namespace {
using TickMs_t = uint32_t(__fastcall *)();
int NowMs() {
return static_cast<int>(
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
}
// Spell.dbc field readers (mirrors the small locals in Spell::Cast).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
const char *n =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
return (n != nullptr) ? n : "";
}
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
if (iconRec == nullptr)
return "";
const char *p = *reinterpret_cast<const char *const *>(iconRec + 0x04);
return (p != nullptr) ? p : "";
}
int SpellSchoolIndex(const uint8_t *rec) {
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
}
// Classify a spell's control-loss type from its EffectApplyAuraName array, or
// null if it applies no control-loss aura. Priority-ordered (strongest control
// first) so a multi-effect spell resolves to one locType, matching retail.
const char *ClassifyControlLoss(const uint8_t *rec) {
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
return nullptr;
}
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
// One active lockout per school index (0..6); active while now < endMs.
// Self-expiring, per-process — a stale entry from before a relog is already
// in the past on the shared uptime clock, so no reset is needed.
struct SchoolLock {
int startMs;
int endMs;
};
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
CooldownHandler_t g_origCooldown = nullptr;
int __stdcall Cooldown_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
int count = 0;
int firstDur = 0;
bool uniform = true;
int schoolIdx = -1;
while (packet->m_read + 8 <= packet->m_size && count < 256) {
const uint32_t spellID = Net::Read<uint32_t>(packet);
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
if (count == 0)
firstDur = dur;
else if (dur != firstDur)
uniform = false;
if (schoolIdx < 0) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec != nullptr) {
const int s = SpellSchoolIndex(rec);
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
schoolIdx = s;
}
}
++count;
}
// A uniform-duration batch of >=2 same-school spells is a school
// lockout (ProhibitSpellSchool); single/varied packets are normal
// cooldowns and ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
}
}
packet->m_read = saved; // restore before the engine re-parses
}
return g_origCooldown(opCode, packet);
}
const Game::HookAutoRegister _cooldownHook{
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
reinterpret_cast<void **>(&g_origCooldown)};
// ---- Aggregated active-effect list ----------------------------------------
struct LocEntry {
const char *locType;
int spellID; // 0 = unknown (school interrupt)
const uint8_t *rec; // spell record for name/icon; null for school interrupt
int schoolMask; // lockoutSchool
int startMs; // 0 = unknown
int endMs; // 0 = unknown / no timing
};
int BuildList(LocEntry *out, int maxOut) {
const int now = NowMs();
int n = 0;
// School-interrupt lockouts first.
for (int s = 0; s < Offsets::SPELL_SCHOOL_COUNT && n < maxOut; ++s) {
if (g_schoolLock[s].endMs != 0 && now < g_schoolLock[s].endMs) {
LocEntry &e = out[n++];
e.locType = "SCHOOL_INTERRUPT";
e.spellID = 0;
e.rec = nullptr;
e.schoolMask = 1 << s;
e.startMs = g_schoolLock[s].startMs;
e.endMs = g_schoolLock[s].endMs;
}
}
// CC debuffs on the player.
const uint8_t *player = Unit::Identity::PlayerObject();
if (player != nullptr) {
const uint64_t playerGuid = Unit::Identity::PlayerGuid();
for (int i = 1; n < maxOut; ++i) {
const int slot = Aura::Data::FindNthSlot(
player, i, Aura::Data::Filter::Harmful, false);
if (slot < 0)
break;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
if (spellID == 0)
continue;
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
continue;
const char *locType = ClassifyControlLoss(rec);
if (locType == nullptr)
continue;
LocEntry &e = out[n++];
e.locType = locType;
e.spellID = static_cast<int>(spellID);
e.rec = rec;
e.schoolMask = 0;
e.startMs = 0;
e.endMs = 0;
uint64_t caster = 0;
uint32_t expMs = 0, durMs = 0;
if (Aura::Source::Get(playerGuid, spellID, &caster, &expMs, &durMs) &&
expMs != 0) {
e.endMs = static_cast<int>(expMs);
if (durMs != 0)
e.startMs = static_cast<int>(expMs - durMs);
}
}
}
return n;
}
// ---- LOSS_OF_CONTROL_ADDED / _UPDATE events (WorldTick poll-and-diff) ------
//
// Effect *expiry* (a school lockout timing out, a CC aura falling off) has no
// engine packet or callback, so a per-frame diff is the only complete signal.
// Firing from the WorldTick tail also keeps the Lua handlers out of the
// mid-packet contexts the state is updated in (the OnTooltipSetItem fire-safety
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
// the per-frame cost is negligible.
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
// spellID, a school interrupt by its school mask (spellID 0).
struct EffectKey {
int spellID;
int schoolMask;
};
EffectKey g_prev[32];
int g_prevCount = 0;
bool ContainsKey(const EffectKey *list, int count, EffectKey key) {
for (int i = 0; i < count; ++i)
if (list[i].spellID == key.spellID &&
list[i].schoolMask == key.schoolMask)
return true;
return false;
}
void OnWorldTick() {
LocEntry entries[32];
const int n = BuildList(entries, 32);
EffectKey cur[32];
for (int i = 0; i < n; ++i)
cur[i] = EffectKey{entries[i].spellID, entries[i].schoolMask};
bool changed = false;
for (int i = 0; i < n; ++i) {
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
changed = true;
}
}
for (int i = 0; i < g_prevCount && !changed; ++i) {
if (!ContainsKey(cur, n, g_prev[i]))
changed = true; // an effect ended (fell off / lockout expired)
}
// Any add or removal fires one coalesced "re-scan" signal. The payload is
// the affected unit — always "player" here (vanilla only exposes the local
// player's control-loss state), but passing it keeps the event shape
// forward-compatible with per-unit tracking and matches modern WoW, whose
// LOSS_OF_CONTROL_UPDATE carries the unit token.
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
for (int i = 0; i < n; ++i)
g_prev[i] = cur[i];
g_prevCount = n;
}
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
int __fastcall Script_GetActiveLossOfControlDataCount(void *L) {
LocEntry entries[32];
const int n = BuildList(entries, 32);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 1;
}
// `C_LossOfControl.GetActiveLossOfControlData(index)` -> LossOfControlData, or
// nil if index is out of range.
int __fastcall Script_GetActiveLossOfControlData(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(
L, "Usage: C_LossOfControl.GetActiveLossOfControlData(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
LocEntry entries[32];
const int n = BuildList(entries, 32);
if (index < 1 || index > n)
return 0; // nil
const LocEntry &e = entries[index - 1];
const int now = NowMs();
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "locType", e.locType);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(e.spellID));
Game::Lua::SetFieldString(
L, "displayText", (e.rec != nullptr) ? SpellName(e.rec) : "Interrupted");
Game::Lua::SetFieldString(
L, "iconTexture", (e.rec != nullptr) ? SpellIconPath(e.rec) : "");
// Timing (seconds, GetTime() epoch). Nullable — leave a field unset (nil)
// when we don't know it, per the modern contract.
if (e.endMs != 0 && now < e.endMs) {
Game::Lua::SetFieldNumber(L, "timeRemaining", (e.endMs - now) * 0.001);
if (e.startMs != 0) {
Game::Lua::SetFieldNumber(L, "startTime", e.startMs * 0.001);
Game::Lua::SetFieldNumber(L, "duration",
(e.endMs - e.startMs) * 0.001);
}
}
Game::Lua::SetFieldNumber(L, "lockoutSchool",
static_cast<double>(e.schoolMask));
Game::Lua::SetFieldNumber(L, "priority", 0);
Game::Lua::SetFieldNumber(L, "displayType", 2); // show for full duration
// auraInstanceID: nil (vanilla has no aura instance IDs) — field omitted.
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction(
"C_LossOfControl", "GetActiveLossOfControlDataCount",
&Script_GetActiveLossOfControlDataCount);
Game::Lua::RegisterTableFunction("C_LossOfControl",
"GetActiveLossOfControlData",
&Script_GetActiveLossOfControlData);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace LossOfControl
+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
+610
View File
@@ -0,0 +1,610 @@
// 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.GetMapOverlays([areaID])` — every WorldMapOverlay.dbc entry for
// a zone, regardless of exploration, with the tile grid fully resolved.
//
// The engine's own surface (GetNumMapOverlays / GetMapOverlayInfo) only
// exposes DISCOVERED overlays — the exploration gate is a server-fed
// list of discovered overlay IDs — which is why map addons (pfUI's
// mapreveal, Cartographer …) ship hand-measured overlay tables to draw
// unexplored areas. This reads the DBC directly instead: live data for
// every zone, on any client, no per-server table drift.
//
// With no argument, returns overlays for the world map's CURRENT view
// (mirroring the resolution the engine's GetMapInfo does: continent /
// zone selection → WorldMapArea row). With a numeric `areaID`
// (AreaTable id — the stable zone identity), resolves the WorldMapArea
// row whose areaID column matches.
//
// ## Why the tile grid is resolved here (Octo data quirks)
//
// An overlay's art is `<textureName>1.blp, 2, …` numbered row-major on a
// 256px grid, canonically `ceil(w/256) x ceil(h/256)` tiles with only
// the last column/row allowed partial. On stock 1.12 data that formula
// is always right; on Octo it is not, in three ways (established
// empirically by the InkLab map generator, mirrored here):
//
// 1. FOREIGN TILES: unrelated art continues an overlay's number
// sequence in the same folder (Icepoint Rock's folder carries
// `kaneqnuun` tiles, Hyjal carries Tel Abim art). Anything derived
// from "what files exist" draws duplicate landmass. The overlay
// list must come ONLY from the DBC (which this module guarantees),
// and tiles past the true grid must be dropped.
// 2. SLIVER COLUMNS: the DBC rect can round away a narrow edge column
// (kaneqnuun's real art has a third, 8px-wide column; its DBC `w`
// yields 2 columns). Trusting ceil(w/256) both loses the sliver
// and — worse — shears the row-major layout into diagonal strips.
// The art itself reveals the truth: only a row's LAST tile may be
// narrower than 256, so the first partial-width tile (searched one
// past the DBC's column count) marks the real column count, and
// the first partial-height tile marks the last row.
// 3. UPSCALED RE-EXPORTS: the same rect re-exported at higher
// resolution (Stonetalon's WindshearCrag: 4 tiles for a 1-tile
// rect). All tiles are real and must be kept, drawn scaled to the
// rect. Only distinguishable from quirk 1 when the art has NO
// partial edge anywhere AND the DBC rect is a whole multiple of
// 256 in both dimensions; the grid shape is then recovered from
// the aspect ratio (cols ≈ round(sqrt(n·w/h))).
//
// Tile dimensions come from the BLP headers via the engine's own VFS
// reader (`FUN_FILE_READ`, so patch-MPQ overrides are honored). Both
// BLP1 and BLP2 keep width/height at header offsets +0x0C/+0x10.
// Resolution runs once per overlay per session (cached).
//
// ## HD map patches (uniform tile upscale)
//
// A custom `patch-*.mpq` can replace the overlay art with higher-res tiles
// (every 256px tile re-exported at 256*scale). Because we read the *file*
// dimensions, that upscale must not be assumed to be 256: the resolver
// detects the full-cell size `S` (the largest tile dimension = 256*scale)
// and expresses both the partial-tile threshold and the texcoords relative
// to it. Draw sizes/positions remain in logical map pixels (the patch changes
// texture resolution, not the map canvas), so an interior tile reads texcoord
// 1.0 at any scale and stock data (S == 256) is byte-identical to before.
// The upscale factor need not be integral; only a uniform per-overlay scale
// is assumed.
//
// ## Return shape
//
// 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.
//
// 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>
#include <cstring>
#include <cmath>
namespace Map::Overlays {
namespace {
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
unsigned int *outSize, unsigned int extraBytes,
int flag1, int flag2);
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
int flags);
constexpr int kMaxTiles = 64;
// Smallest power of two >= v (min 16), matching the engine's own
// WorldMapFrame tile-file-size loop. A tile's content fills content/NextPot
// of its (power-of-two) file — a fraction that's identical whether the file
// is stock or HD-upscaled, so texcoords derived from it are scale-invariant.
int NextPot(int v) {
int p = 16;
while (p < v)
p <<= 1;
return p;
}
// Resolved tile grid for one overlay, cached for the session (DBC and
// MPQ contents are immutable per process).
struct ResolvedOverlay {
uint8_t cols;
uint8_t rows;
uint8_t count; // tiles to draw (foreign tail already dropped)
bool upscaled;
uint16_t cell; // full-cell file size (256 on stock, 256*scale on an HD patch)
uint16_t fileW[kMaxTiles]; // BLP file dimensions per tile
uint16_t fileH[kMaxTiles];
};
ResolvedOverlay **g_cache = nullptr;
int g_cacheSize = 0;
// Reads one BLP's header dimensions through the engine VFS. Returns
// false when the file doesn't exist (end of the tile sequence) or isn't
// a BLP.
bool ReadBlpDims(const char *path, uint16_t *outW, uint16_t *outH) {
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *buf = nullptr;
unsigned int size = 0;
if (FileRead(0, path, &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
return false;
bool ok = false;
const uint8_t *b = static_cast<const uint8_t *>(buf);
if (size >= 0x14 && b[0] == 'B' && b[1] == 'L' && b[2] == 'P') {
const uint32_t w = *reinterpret_cast<const uint32_t *>(b + 0x0C);
const uint32_t h = *reinterpret_cast<const uint32_t *>(b + 0x10);
if (w > 0 && w <= 4096 && h > 0 && h <= 4096) {
*outW = static_cast<uint16_t>(w);
*outH = static_cast<uint16_t>(h);
ok = true;
}
}
SMemFree(buf, __FILE__, __LINE__, 0);
return ok;
}
// The quirk disambiguation (see module comment). `basePath` is the
// numberless texture path; dbcW/dbcH the overlay's DBC rect.
void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
int dbcH) {
char path[0x120];
int n = 0;
while (n < kMaxTiles) {
std::snprintf(path, sizeof(path), "%s%d.blp", basePath, n + 1);
if (!ReadBlpDims(path, &out->fileW[n], &out->fileH[n]))
break;
++n;
}
int dbcCols = (dbcW + 255) / 256;
int dbcRows = (dbcH + 255) / 256;
if (dbcCols < 1)
dbcCols = 1;
if (dbcRows < 1)
dbcRows = 1;
// Full-cell file size. On stock data a full (interior) tile is 256px; an
// HD map patch (patch-*.mpq) upscales every tile uniformly, so a full cell
// is 256*scale. Full tiles are the largest; edges/slivers are smaller — so
// the max tile dimension is the full-cell size. Partial-tile detection
// (and the texcoords in PushTiles) must compare against this S, not a
// hardcoded 256, or an HD edge tile (e.g. a 200px edge upscaled to 400px)
// reads as "full" and shears the row-major grid into diagonal strips.
int S = 256;
for (int i = 0; i < n; ++i) {
if (out->fileW[i] > S)
S = out->fileW[i];
if (out->fileH[i] > S)
S = out->fileH[i];
}
out->cell = static_cast<uint16_t>(S);
// Real column count: the first partial-width tile is a row's right
// edge. Search one past dbcCols so a sliver column the DBC width
// rounds off is still found.
int cols = dbcCols;
bool widthEdge = false;
for (int i = 0; i < n && i <= dbcCols; ++i) {
if (out->fileW[i] < S) {
cols = i + 1;
widthEdge = true;
break;
}
}
// Real row count: the row holding the first partial-height tile is
// the last row.
int rows = dbcRows;
bool heightEdge = false;
for (int i = 0; i < n; ++i) {
if (out->fileH[i] < S) {
rows = i / cols + 1;
heightEdge = true;
break;
}
}
int limit = cols * rows;
bool upscaled = false;
// Upscale exception: no partial edge anywhere and a whole-tile DBC
// rect means the extras are higher-res art for the same rect, not
// foreign — keep them all; recover the grid from the aspect ratio.
if (!widthEdge && !heightEdge && dbcW % 256 == 0 && dbcH % 256 == 0 &&
n > limit) {
upscaled = true;
cols = static_cast<int>(std::floor(
std::sqrt(static_cast<double>(n) * dbcW / dbcH) + 0.5));
if (cols < 1)
cols = 1;
if (cols > n)
cols = n;
limit = n;
rows = (n + cols - 1) / cols;
}
if (limit > n)
limit = n;
if (cols < 1)
cols = 1;
if (rows < 1)
rows = 1;
out->cols = static_cast<uint8_t>(cols);
out->rows = static_cast<uint8_t>(rows);
out->count = static_cast<uint8_t>(limit);
out->upscaled = upscaled;
}
const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
int dbcW, int dbcH) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
if (g_cache == nullptr || g_cacheSize < count + 1) {
auto **fresh = new ResolvedOverlay *[count + 1]();
for (int i = 0; i < g_cacheSize; ++i)
fresh[i] = g_cache[i];
delete[] g_cache;
g_cache = fresh;
g_cacheSize = count + 1;
}
if (overlayId < 0 || overlayId >= g_cacheSize)
return nullptr;
if (g_cache[overlayId] == nullptr) {
auto *r = new ResolvedOverlay();
ResolveTiles(r, basePath, dbcW, dbcH);
g_cache[overlayId] = r;
}
return g_cache[overlayId];
}
// 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,
int dbcW, int dbcH, int offX, int offY) {
Game::Lua::NewTable(L);
if (r == nullptr || r->count == 0)
return;
const int cols = r->cols;
char file[0x120];
for (int i = 0; i < r->count; ++i) {
const int col = i % cols;
const int row = i / cols;
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
double drawW, drawH, tcX, tcY, posX, posY;
if (r->upscaled) {
// Higher-res re-export of the same rect: tiles split the DBC
// rect evenly, full texcoords.
drawW = static_cast<double>(dbcW) / r->cols;
drawH = static_cast<double>(dbcH) / r->rows;
tcX = 1.0;
tcY = 1.0;
posX = offX + col * drawW;
posY = offY + row * drawH;
} else {
// Canonical 256px logical grid. Draw sizes/positions stay in
// logical map pixels; the texcoord is the content fraction of the
// (possibly HD-upscaled) file. `scale` = file px per logical px
// (1 on stock, 2/4 on an HD patch), so a full interior tile reads
// texCoord 1.0 regardless of file size, and an edge's content
// (remainder * scale) is measured against its actual file width.
const int fileW = r->fileW[i];
const int fileH = r->fileH[i];
const double scale = (r->cell > 0) ? (r->cell / 256.0) : 1.0;
double logicalW = 256.0, logicalH = 256.0;
tcX = 1.0;
tcY = 1.0;
if (col == r->cols - 1) {
const int rem = dbcW - 256 * (r->cols - 1);
if (rem <= 0) {
// Sliver the DBC width rounded away: whole file is content
// (its logical width is the file width de-scaled).
logicalW = fileW / scale;
} else {
// Content fills rem/NextPot(rem) of the POT tile file.
// Scale-invariant, so an HD-upscaled file (POT*scale)
// reads the same fraction — using the raw file width here
// would halve the texcoord on any tile the patch doubled.
logicalW = rem;
tcX = rem / static_cast<double>(NextPot(rem));
}
}
if (row == r->rows - 1) {
const int rem = dbcH - 256 * (r->rows - 1);
if (rem <= 0) {
logicalH = fileH / scale;
} else {
logicalH = rem;
tcY = rem / static_cast<double>(NextPot(rem));
}
}
drawW = logicalW;
drawH = logicalH;
posX = offX + col * 256;
posY = offY + row * 256;
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "file", file);
Game::Lua::SetFieldNumber(L, "width", drawW);
Game::Lua::SetFieldNumber(L, "height", drawH);
Game::Lua::SetFieldNumber(L, "texCoordX", tcX);
Game::Lua::SetFieldNumber(L, "texCoordY", tcY);
Game::Lua::SetFieldNumber(L, "offsetX", posX);
Game::Lua::SetFieldNumber(L, "offsetY", posY);
Game::Lua::SetTable(L, -3);
}
}
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;
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(wmaRow));
const char *mapDir =
(wmaRec != nullptr)
? *reinterpret_cast<const char *const *>(wmaRec +
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;
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;
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);
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 offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
// 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", hasTexture ? texName : "");
Game::Lua::SetFieldString(L, "texturePath", basePath);
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
Game::Lua::SetFieldNumber(L, "offsetX", offX);
Game::Lua::SetFieldNumber(L, "offsetY", offY);
Game::Lua::SetFieldNumber(
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);
Game::Lua::SetFieldBool(L, "upscaled", r->upscaled);
}
Game::Lua::PushString(L, "tiles");
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);
}
}
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.
+305
View File
@@ -0,0 +1,305 @@
// 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_PlayerInfo.CanUseItem(itemID)` — true iff the local player meets the
// item's *use/equip* requirements. This is the "is the item red in the
// tooltip" gate, and is distinct from its siblings:
// - `C_Item.IsEquippableItem` — does the item fit *some* slot (static).
// - `IsUsableItem` — is the item's on-use ability castable now.
// - `C_PlayerInfo.CanUseItem` — may the player equip/use it at all.
//
// So this is the answer to "mail chest on a Mage" (proficiency), "level-40
// item on a level-20" (RequiredLevel), and class/race-restricted gear.
//
// Not in vanilla 1.12 nor in our 3.3.5 reference client (the modern API
// landed in 1.14.4 / 3.4.2 / 10.0.5), so there's no cross-binary source;
// the logic mirrors the requirement checks the 1.12 item tooltip builder
// (`FUN_0052B650`) inlines for its red/green line coloring — the same
// `bVar23` "can't use this" flag, minus the display.
//
// Checks (all must pass):
// 1. Proficiency — for weapon/armor the engine keeps a per-item-class
// subclass-proficiency bitmask (VAR_PROFICIENCY_TABLE, fed by
// SMSG_SET_PROFICIENCY). `mask[m_class] & (1 << m_subClass)` must be
// set. Item classes with no proficiency concept store mask 0 and are
// unrestricted (matches the tooltip's `mask != 0` gate).
// 2. RequiredLevel <= player level.
// 3. AllowableClass / AllowableRace masks include the player (0 or all-
// bits = no restriction).
// 4. RequiredSkill — the player must have the item's skill line at an
// effective rank (current + temp bonus) >= RequiredSkillRank (e.g. a
// mount "Requires Riding (150)"). Via FUN_SKILL_LINE_TO_SLOT.
// 5. RequiredSpell — the player must know the item's prerequisite spell,
// or a higher rank of it: a crafting specialization ("Requires
// Armorsmith") or a proficiency spell. Via the spell-knowledge bitmap
// plus the engine's rank-chain walk.
// 6. RequiredHonorRank / RequiredCityRank — PvP-rank gates: the player's
// honor rank (>= required) and earned PvP-medal bitmask.
// 7. RequiredReputation — current standing with the item's faction must
// reach the required reaction band.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a synchronous
// peek that returns false without firing a load.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::CanUseItem {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
// `__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);
// `__fastcall(factionID) -> current total reputation` (base + bonus), 0
// for factions not in the player's list. See FUN_REPUTATION_GET_STANDING.
using RepStanding_t = int(__fastcall *)(int factionID);
// `__thiscall(player /*ecx*/, spellID) -> nonzero` if the player knows a
// higher rank in spellID's chain. See FUN_SPELL_RANK_CHAIN_KNOWN.
using SpellRankChainKnown_t = int(__thiscall *)(void *player, uint32_t spellID);
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);
}
// 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) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << ((index1Based - 1) & 31))) == 0;
}
// Per-item-class proficiency bitmask lookup. Returns false only when the
// item's class *has* a proficiency concept (mask != 0) and the item's
// subclass bit is unset.
bool ProficiencyMet(const uint8_t *record) {
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
if (itemClass > 16) // table is 17 entries (m_class 0..16)
return true;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE));
const uint32_t mask = table[itemClass];
if (mask == 0)
return true; // class has no proficiency concept (consumables, …)
return (mask & (1u << (subClass & 31))) != 0;
}
// RequiredSkill + RequiredSkillRank gate (e.g. a mount "Requires Riding
// (150)", a profession tool "Requires Engineering (200)"). Maps the
// item's SkillLine.dbc row to the player's skill slot; the player must
// have the line at an effective rank (current + temp bonus) >= required.
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
if (reqSkill == 0)
return true; // no skill requirement
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
if (slot < 0)
return false; // player doesn't have the skill line at all
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return false;
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
if (rank != 0) // add the temp bonus only when there's a base value
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
return rank >= reqRank;
}
// True if the local player knows `spellID`, via the engine's spell-
// knowledge bitmap (the active-player path of `FUN_0060c740`, read
// directly — no player object needed). Covers trained abilities, talents,
// racials, profession recipes, and crafting specializations (Armorsmith,
// Weaponsmith, the Engineering branches, …), which are all single learned
// spells that land in this bitmap.
bool PlayerKnowsSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// RequiredSpell gate — the item's `RequiredSpell` (a Spell.dbc row) is the
// prerequisite the "Requires <spell>" tooltip line reflects: a crafting
// specialization like Armorsmith, or an armor/weapon proficiency spell.
// The player must know it. An unset (0) or invalid (no Spell.dbc record)
// requirement is no gate — matching the tooltip's validity guard, which
// skips the line for a missing row.
//
// Mirrors the tooltip's `bitmap(reqSpell) OR rank-chain(reqSpell)`: met if
// the player knows the exact RequiredSpell, or a higher rank of it (the
// latter covers a ranked RequiredSpell — `FUN_SPELL_RANK_CHAIN_KNOWN`
// walks the forward chain). Item RequiredSpell is a non-ranked
// specialization/proficiency in practice, so the chain walk is
// belt-and-suspenders, but it makes the check exact.
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
const int reqSpell = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
if (reqSpell <= 0)
return true; // no requirement
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
return true; // invalid Spell.dbc row → tooltip skips the gate
if (PlayerKnowsSpell(reqSpell))
return true; // knows the exact spell
auto chainKnown = reinterpret_cast<SpellRankChainKnown_t>(
Offsets::FUN_SPELL_RANK_CHAIN_KNOWN);
return chainKnown(const_cast<uint8_t *>(player),
static_cast<uint32_t>(reqSpell)) != 0;
}
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint8_t rank = *(sub + Offsets::OFF_CGPLAYER_HONOR_RANK);
return rank >= req;
}
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
// when the player has earned bit (R-1) in the medal mask.
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
return (medals & (1u << ((req - 1) & 31))) != 0;
}
// RequiredReputation — current standing with the item's faction must
// reach the required reaction band. The band index (0=Hated … 7=Exalted)
// indexes the engine's reaction-min threshold table; the player's total
// rep with the faction must be >= that threshold.
bool RequiredReputationMet(const uint8_t *record) {
const int faction = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
if (faction == 0)
return true;
const uint32_t band = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
return true;
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
const int current = getStanding(faction);
const int threshold = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
return current >= threshold;
}
bool ComputeCanUse(int itemID) {
if (itemID <= 0)
return false;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false; // not cached → sync false, no load fired
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return false;
if (!ProficiencyMet(record))
return false;
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return false;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return false;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return false;
if (!RequiredSkillMet(player, record))
return false;
if (!RequiredSpellMet(player, record))
return false;
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (!RequiredHonorRankMet(sub, record))
return false;
if (!RequiredCityRankMet(sub, record))
return false;
if (!RequiredReputationMet(record))
return false;
return true;
}
int __fastcall Script_CanUseItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
Game::Lua::PushBool(L, ComputeCanUse(itemID));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PlayerInfo", "CanUseItem", &Script_CanUseItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Player::CanUseItem
+222
View File
@@ -0,0 +1,222 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` — the WotLK-era
// per-slot paperdoll event. Vanilla only fires UNIT_INVENTORY_CHANGED for
// the player, which doesn't say WHICH slot changed; gear trackers and stat
// sheets built against 3.3+ gate their refresh on this event.
//
// Fully event-driven via the engine's own descriptor-field observer system
// (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h). The engine's
// inventory setup (FUN_INV_OBSERVER_SETUP, once per enter-world) registers
// change observers for the bag/backpack/bank/keyring GUID fields of the
// player descriptor — but never for the 19 equipment slots (0x4A8..0x538).
// We co-hook that setup and register our own observer per equipment field,
// mirroring the engine's registration shape exactly (bank 4, size 8). The
// dispatcher invokes us only when a slot's item GUID actually changed
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
// on our side, and the nodes share the engine observers' lifetime (they
// die with the player object; the setup re-runs each enter-world).
//
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
//
// Also `UPDATE_INVENTORY_DURABILITY` (no payload) — fires when any
// equipped item's durability changes (combat damage, death, repairs) —
// via a co-hook on the engine's inventory-alerts recompute
// (FUN_INVENTORY_ALERTS_RECOMPUTE): the engine itself calls that whenever
// an owned item's fields change (its item post-update handler FUN_005D9A90
// routes every item values-update through it) and on equip/enter-world
// paths, so it IS the engine's own "durability may have changed" signal.
// Post-original we diff a per-slot {itemGuid, durability} snapshot and
// fire only on a real change. NOT done with bank-1 (ITEM field) descriptor
// observers: those register without error but are never invoked — the
// registrar's internal wrapper lookup (FUN_00464890, the observer
// registry, distinct from the object manager) silently no-ops for item
// objects, verified empirically (item values-updates arrive and the
// player-bank observers fire, item-bank ones never do).
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "event/Custom.h"
#include "player/StatSignal.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::Equipment {
namespace {
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
constexpr const char *kEvtDurability = "UPDATE_INVENTORY_DURABILITY";
const Event::Custom::AutoReserve _r2{kEvtDurability};
// Whether the slot currently holds an item — read from the player's
// inventory-manager GUID array, the same source the engine's own bag
// observer (FUN_004F8DB0) treats as the post-change truth when its field
// observer fires. The player is resolved from the GUID the observer
// callback receives, via the NON-THROWING object resolver — the same call
// FUN_004F8DB0 makes. NOT via FUN_RESOLVE_UNIT_TOKEN("player"): that's the
// assert-frame helper that raises a Lua error when the token doesn't
// resolve, and this runs inside SMSG_UPDATE_OBJECT processing, where the
// player object can be mid-create and momentarily unresolvable — a Lua
// error thrown from there unwinds through raw engine code.
bool SlotHasItem(uint32_t guidLo, uint32_t guidHi, int slot0Based) {
using ResolveByGUID_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int line);
auto resolve =
reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *player = static_cast<const uint8_t *>(resolve(
Offsets::OBJ_TYPE_PLAYER, "ClassicAPI", guidLo, guidHi, 0x172));
if (player == nullptr)
return false;
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
if (static_cast<uint32_t>(slot0Based) >= count)
return false;
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
return guids != nullptr && guids[slot0Based] != 0;
}
// --- PLAYER_EQUIPMENT_CHANGED (player-descriptor observers) -------------
// Descriptor-field observer callback — ABI per the Offsets.h note:
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
// when the watched equipment GUID field actually changed.
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/,
void * /*userArg*/) {
const int slot0 =
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
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;
}
// Co-hook on the engine's inventory observer setup: after it registers the
// bag-range observers, register ours for the equipment range it skips.
// Same registrar, same shape — only the field offsets differ.
using RegisterObserver_t = void(__fastcall *)(
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1, void *userArg2);
using ObserverSetup_t = void(__fastcall *)();
ObserverSetup_t g_origSetup = nullptr;
// One-time (per process) seed latch for the durability diff below — the
// very first recompute seeds the snapshot silently instead of firing for
// the initial gear population. Deliberately NOT reset per enter-world:
// death/release/graveyard teleports re-run the setup, and a per-setup
// reset made the post-teleport recompute re-seed silently — swallowing
// the death's own durability loss (verified in-game: repairs fired,
// deaths didn't). Keeping the snapshot across loading screens means
// changes that land around them diff normally; the cost is at most one
// spurious fire on relog to a different character, which matches
// modern's login-time fire anyway.
bool g_durSnapValid = false;
void __fastcall ObserverSetup_h() {
g_origSetup();
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return;
auto reg = reinterpret_cast<RegisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_REGISTER);
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
nullptr, nullptr);
}
}
static const Game::HookAutoRegister _setupHook{
Offsets::FUN_INV_OBSERVER_SETUP,
reinterpret_cast<void *>(&ObserverSetup_h),
reinterpret_cast<void **>(&g_origSetup)};
// --- UPDATE_INVENTORY_DURABILITY (alerts-recompute co-hook) --------------
struct SlotDurability {
uint64_t guid; // equipped item, 0 = empty slot
uint32_t durability; // ITEM_FIELD_DURABILITY, 0 when empty/unresolved
};
SlotDurability g_durSnap[EquipmentSet::SLOT_COUNT];
uint32_t ItemDurability(uint64_t guid) {
if (guid == 0)
return 0;
const uint8_t *item = EquipmentSet::Locations::ResolveItemByGUID(guid);
if (item == nullptr)
return 0;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_DURABILITY);
}
using AlertsRecompute_t = void(__fastcall *)();
AlertsRecompute_t g_origAlertsRecompute = nullptr;
void __fastcall AlertsRecompute_h() {
g_origAlertsRecompute();
uint64_t paperdoll[EquipmentSet::SLOT_COUNT];
EquipmentSet::Locations::SnapshotPaperdoll(paperdoll);
bool changed = false;
for (int i = 0; i < EquipmentSet::SLOT_COUNT; ++i) {
const SlotDurability cur{paperdoll[i], ItemDurability(paperdoll[i])};
if (g_durSnap[i].guid != cur.guid ||
g_durSnap[i].durability != cur.durability) {
changed = true;
g_durSnap[i] = cur;
}
}
if (!g_durSnapValid) {
g_durSnapValid = true; // first recompute of the session seeds only
return;
}
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
}
static const Game::HookAutoRegister _alertsHook{
Offsets::FUN_INVENTORY_ALERTS_RECOMPUTE,
reinterpret_cast<void *>(&AlertsRecompute_h),
reinterpret_cast<void **>(&g_origAlertsRecompute)};
} // namespace
} // namespace Player::Equipment
+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
+117 -22
View File
@@ -23,13 +23,19 @@
// parse callbacks, and the registry handles load/save so neither
// this module nor `Charlist::Order` clobbers the other's lines.
//
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents. v2 format. One entry per line:
// `guidHi guidLo classID raceID sex name`
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents, SHARED across every account on the
// same server. Keyed by realmlist address (unambiguous server
// identity — can't collide across unrelated private servers) plus
// the realm name, rather than by account, so alts on different
// accounts pool into one file. Not under `Account\`, so we create
// the directory tree ourselves. v4 format. One entry per line:
// `name guid classID raceID sex`
// (fields tab-separated). Loaded once the realm name is
// available post-login. Saved when (a) a recent write hasn't
// been flushed AND (b) at least 30 seconds have elapsed since
// the prior save, OR (c) DLL teardown / `Flush()` is called.
// available post-login (migrating a pre-existing per-account file
// in on first load). Saved when (a) a recent write hasn't been
// flushed AND (b) at least 30 seconds have elapsed since the prior
// save, OR (c) DLL teardown / `Flush()` is called.
//
// Population sources:
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
@@ -69,6 +75,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <istream>
#include <ostream>
#include <sstream>
#include <string>
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
// ----- path resolution -----
// realmList CVar value (the realmlist address, e.g.
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
// friendly realm name it can't collide across unrelated private servers,
// so it's the outer key for the shared cache path. Mirrors
// `Security::CredStore`'s realm scoping.
const char *ReadRealmList() {
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
return reinterpret_cast<GetRealmList_t>(
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
}
// Filesystem-safe path component. We create these directories ourselves
// (WoW doesn't), so any character Windows rejects in a path — or that
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
// Deterministic, so a realm always maps to the same folder.
std::string SanitizeComponent(const char *s) {
std::string out;
for (const char *p = s; p != nullptr && *p; ++p) {
const char c = *p;
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
c == '_';
out.push_back(safe ? c : '_');
}
return out;
}
// Shared, server-specific cache path:
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
// Not under Account\, so alts on different accounts of the same server
// share one pool. Empty (defer) until both realmlist + realm are known.
std::string CachePath() {
const char *realmList = ReadRealmList();
if (realmList == nullptr || realmList[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
std::string out = "WTF\\ClassicAPI\\";
out += SanitizeComponent(realmList);
out += '\\';
out += SanitizeComponent(realm);
out += "\\ClassicAPI_NameCache.txt";
return out;
}
// The pre-shared per-account path, used only to migrate an existing
// cache into the shared location on first load. Built exactly as it was
// written (raw account + realm, unsanitized) so it matches the file
// sitting in WoW's own folders.
std::string OldCachePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
@@ -154,6 +211,21 @@ std::string CachePath() {
return out;
}
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
// directories, so a save into a fresh realm folder would silently fail
// without this. CreateDirectory on an existing dir no-ops.
void EnsureParentDirs(const std::string &filePath) {
for (size_t pos = 0;;) {
const size_t slash = filePath.find('\\', pos);
if (slash == std::string::npos)
break; // remainder is the filename
if (slash > 0)
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
pos = slash + 1;
}
}
// ----- account settings (registered with Settings::Account) -----
void SettingsReset() {
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
return changed;
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (!file.is_open())
return; // first run for this realm
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
// it's the primary key; GUID is a peripheral field carried for
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
// into the store. Name first since it's the primary key; GUID is a
// peripheral field carried for GUID-keyed lookups. Variable-width hex
// GUID (no leading zeros). Shared by the normal load and the one-time
// migration from the old per-account file.
void LoadFromStream(std::istream &file) {
std::string line;
while (std::getline(file, line)) {
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
}
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (file.is_open()) {
LoadFromStream(file);
return;
}
// No shared file yet. One-time migration: seed from the old
// per-account file if it exists, then mark dirty so the next save
// writes the merged data into the shared path. Only when the shared
// file is absent — once it exists it's authoritative (a second
// account's old-only entries just repopulate via scan / SMSG).
const std::string oldPath = OldCachePath();
if (oldPath.empty())
return;
std::ifstream oldFile(oldPath);
if (oldFile.is_open()) {
LoadFromStream(oldFile);
g_dirty = true;
}
}
void SaveCache() {
const std::string path = CachePath();
if (path.empty())
return;
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
// the tree first, else the ofstream open below silently fails.
EnsureParentDirs(path);
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
@@ -319,7 +414,7 @@ void SaveCache() {
// sort/scan key when hand-editing. GUID is variable-width
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
// GUID ever lands here.
out << "# ClassicAPI name cache v4 (per-realm)\n";
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
out << "# name\tguid\tclassID\traceID\tsex\n";
for (const auto &kv : g_entries) {
const std::string &name = kv.first;
+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
+39
View File
@@ -134,6 +134,43 @@ void PushRequirements(void *L, const uint8_t *data) {
}
}
// `C_QuestLog.GetNumQuestObjectives(questID)` — the quest's objective
// count (the length `GetQuestDetails(questID).requirements` would have),
// read straight from the cache block with zero Lua table/string
// materialization. Exists so per-step validation loops (guide addons
// bounds-checking objective indices at load time) stay O(1)-cheap even
// with a warm quest cache — materializing full detail tables for hundreds
// of quests inside a login-path loop can exhaust vanilla's fixed Lua
// memory pool. Returns nil when the quest isn't cached (same contract as
// GetQuestDetails).
int __fastcall Script_GetNumQuestObjectives(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_QuestLog.GetNumQuestObjectives(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
int count = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (npcOrGo[i] != 0)
++count;
if (items[i] != 0)
++count;
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
@@ -245,6 +282,8 @@ int __fastcall Script_GetQuestDetails(void *L) {
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetNumQuestObjectives",
&Script_GetNumQuestObjectives);
}
} // namespace
+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",
@@ -11,18 +11,29 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
// new quest entering the local quest log. Polyfills modern WoW's
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
// signature: `(questLogIndex, questID)`, 1-based log index.
// Quest log delta events — polyfills two modern WoW events from one
// pre-/post-rebuild diff:
//
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
// entering the local quest log. Matches the Cata/WotLK signature,
// 1-based log index (event added in 3.1.0).
//
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
// quest log, for both turn-ins and abandons (event added in 8.0.1;
// Classic-era signature is the bare questID). For turn-ins this fires
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
// complete packet doesn't touch the log; the removal arrives in the
// follow-up quest-log update packets, whose rebuild we diff here.
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
//
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
// chokepoint the engine uses to refresh the Lua-visible quest log
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
// 0x28]`. Every state change that adds, removes, or updates a quest
// flows through here. By snapshotting the log before and after the
// original runs, we can compute the delta and fire QUEST_ACCEPTED
// only for genuine additions.
// original runs, we can compute the delta and fire only for genuine
// additions / removals.
//
// Login suppression: the same function handles the initial bulk-sync
// at character entry, which appears as a single rebuild call adding
@@ -30,7 +41,10 @@
// added in a single rebuild (which can only happen on login / reload
// resync — human input speed can't accept multiple quests in the
// same engine tick). A brand-new character's first accept is
// `0 → 1` and fires correctly.
// `0 → 1` and fires correctly. Removals apply the same rule (a user
// action removes at most one quest per rebuild) and are additionally
// suppressed when the rebuild also added multiple quests — a
// mixed-delta rebuild is a cross-character resync, not gameplay.
#include "Game.h"
#include "Offsets.h"
@@ -39,13 +53,15 @@
#include <cstdint>
#include <unordered_set>
namespace Quest::Accepted {
namespace Quest::LogEvents {
namespace {
constexpr const char *kEventName = "QUEST_ACCEPTED";
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
// Walk the live quest log and collect questIDs of real (non-header)
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
void __fastcall QuestLogRebuild_h(int param_1) {
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
// refires QUEST_LOG_UPDATE without touching the entry array.
// No diff possible, no accept happening, pass through.
// No diff possible, no accept/remove happening, pass through.
if (param_1 == 0) {
QuestLogRebuild_o(param_1);
return;
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// Walk the post-state in index order so the fired event's
// `questLogIndex` matches the 1-based slot the engine just
// assigned. Skip questIDs already in `pre`.
// assigned. Collect the full post-set on the way for the
// removal diff.
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
if (count <= 0)
return;
const auto *entries = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
std::unordered_set<int> post;
if (count > 0)
post.reserve(static_cast<size_t>(count));
int newCount = 0;
int firstNewIdx = 0;
int firstNewID = 0;
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID <= 0)
continue;
post.insert(questID);
if (pre.count(questID) != 0)
continue;
++newCount;
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
}
}
int removedCount = 0;
int firstRemovedID = 0;
for (const int questID : pre) {
if (post.count(questID) != 0)
continue;
++removedCount;
if (removedCount == 1)
firstRemovedID = questID;
}
// Single addition → user-driven accept. Multiple → bulk resync
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = Event::Custom::Lookup(kAcceptedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
// Single removal → user-driven turn-in or abandon. The extra
// `newCount <= 1` gate rejects the one resync shape the removal
// count alone can't: switching to a character whose log shares
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
}
static const Game::HookAutoRegister _hookreg{
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
reinterpret_cast<void *>(&QuestLogRebuild_h),
reinterpret_cast<void **>(&QuestLogRebuild_o)};
} // namespace Quest::Accepted
} // namespace Quest::LogEvents
+48 -27
View File
@@ -17,13 +17,12 @@
//
// Implementation chains the engine's existing primitives:
//
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
// target spell, the engine enters placement mode at this point
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
// fires immediately.
// 1. Initiate the cast: resolve the *exact* `spellID` to its spellbook
// slot (`Spell::Lookup::FindSpellbookSlot` — NOT name resolution, which
// would cast the highest rank) and dispatch through
// `FUN_SPELL_CAST_DISPATCH`. For a ground-target spell, the engine
// enters placement mode at this point (sets `VAR_SPELL_PLACEMENT_STATE`);
// for a normal cast, it fires immediately.
//
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
@@ -41,10 +40,10 @@
#include "Game.h"
#include "Offsets.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
@@ -141,9 +140,16 @@ bool Resolve() {
namespace {
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
}
} // namespace
@@ -151,39 +157,54 @@ bool DispatchSpellCast(int spellID) {
if (spellID <= 0)
return false;
// Hand the spellID off as a numeric string — our `Spell::CastByID`
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
// input into the locale-resolved spell name internally, so the
// engine treats it just like `CastSpellByName("Blizzard")`.
char numBuf[16];
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
// Resolve the EXACT spellID to its spellbook slot. Every rank is its own
// slot, so we must NOT go through name resolution — that walks to the
// highest rank and would cast Blizzard(Rank 6) for a Rank 1 (spellID 10)
// request. `FindSpellbookSlot` returns a 1-based slot; the dispatcher
// wants the 0-based index into `VAR_PLAYER_SPELLBOOK`.
int bookType = 0;
const int slot1 = Spell::Lookup::FindSpellbookSlot(spellID, &bookType);
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
return true;
}
bool DispatchSpellCastByName(const char *name) {
if (name == nullptr || *name == '\0')
return false;
// The engine's resolver strips a trailing `(Rank N)` and returns the
// 0-based spellbook slot (highest rank when no rank is given). This is the
// by-name path — for an exact rank prefer the numeric spellID overload.
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
const int slot = nameToSlot(name, &bookType);
if (slot < 0)
return false; // not in the player's spellbook
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
dispatch(static_cast<unsigned>(slot), bookType,
0, 0.0f); // no implicit target unit; engine handles placement
DispatchSlot(slot, bookType);
return true;
}
namespace {
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
// Accept a numeric spellID (exact rank) or a spell name (the engine's
// resolver parses a trailing "(Rank N)"). IsNumber is checked first so a
// numeric string like "10" still takes the exact-spellID path.
bool dispatched = false;
if (Game::Lua::IsNumber(L, 1)) {
dispatched = DispatchSpellCast(static_cast<int>(Game::Lua::ToNumber(L, 1)));
} else if (Game::Lua::IsString(L, 1)) {
dispatched = DispatchSpellCastByName(Game::Lua::ToString(L, 1));
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
// Spell not in the player's spellbook → match `CastSpellByName`'s
// silent failure.
if (!DispatchSpellCast(spellID)) {
// Bad input / spell not in the player's spellbook → match
// `CastSpellByName`'s silent failure.
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+6
View File
@@ -46,6 +46,12 @@ bool CommitAtCoords(const float coords[3]);
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
// true if the cast was dispatched (spell is in the player's
// spellbook), false otherwise.
//
// The numeric form casts the *exact* spellID (a specific rank). The
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
} // namespace Spell::AtCursor
+11 -3
View File
@@ -43,11 +43,14 @@ namespace Spell::AtUnit {
namespace {
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
// arg2: unit token.
const bool byNumber = Game::Lua::IsNumber(L, 1);
if ((!byNumber && !Game::Lua::IsString(L, 1)) ||
!Game::Lua::IsString(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
@@ -61,7 +64,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
return 1;
}
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
const bool dispatched =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+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
+254 -30
View File
@@ -32,26 +32,37 @@
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// must skip them — they expire on their computed endMs, or earlier via
// the abort co-hooks (ClearCastingSpell / the failure packets) when the
// engine learns the cast died.
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
// (server duration — and the ONLY start signal for cast-then-channel
// spells like Mind Control, whose SpellStart covers just the cast
// phase). The broadcast +0x228 field is NOT polled for the player —
// it lags the channel start by ~1 tick.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
//
// Other units have no engine-stored cast record, so we capture their casts
// from the `SMSG_SPELL_START` packet (the only place the client sees
// another unit's cast + a server-authoritative cast time) into a per-caster
// cache — see the SpellStart co-hook below. Scope:
// Other units have no engine-stored cast TIMES (the engine does keep the
// current-cast spellID at [unit+0xC8C], but nothing temporal), so we
// capture their casts from the `SMSG_SPELL_START` packet (the only place
// the client sees another unit's cast + a server-authoritative cast time)
// into a per-caster cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// from the SpellStart cache. Aborts (interrupt, death, movement,
// cancel) evict the entry the moment the engine stops the unit's cast
// — the CGUnit_C::ClearCastingSpell co-hook below, backstopped by
// co-hooks on both failure packets — so no lingering ghost bar.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
// - Other units' PUSHBACK is invisible: SMSG_SPELL_DELAYED goes only to
// the affected caster (empirically confirmed — during a melee-pushback
// test only the victim's client received it), so a remote bar can't
// stretch. Inherent 1.12 protocol gap, not recoverable client-side.
//
// Modern-signature fields vanilla can't fill are structurally-correct
// placeholders: castID / castBarID = nil, notInterruptible = false,
@@ -104,8 +115,44 @@ TrackedSpell g_channel{0, 0, 0, 0};
// the WorldTick VAR==0 clear must not touch them.
bool g_castFromServer = false;
// Armed once the broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228) has been seen to
// reflect g_channel's spell — the enable for OnWorldTick's channel-stop poll.
// StampChannel re-arms it (false) on every channel stamp so that field's
// ~1-tick start lag can't false-clear a channel we just stamped.
bool g_channelConfirmed = false;
// Stamp the player's channel and re-arm the stop latch. A channel supersedes
// any in-flight cast. Both channel-stamp sites (SMSG_SPELL_START's instant-
// channel path and the MSG_CHANNEL_START handler) route through here so
// neither can forget to reset the latch.
void StampChannel(int spellID, int startMs, int endMs) {
g_channel = TrackedSpell{spellID, startMs, endMs, 0};
g_channelConfirmed = false;
g_cast.spellID = 0;
}
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
// Pushes an engine-ms timestamp to Lua as an UNSIGNED 32-bit value.
//
// `FUN_OS_TICKCOUNT_MS` returns a 64-bit ms tick from one of two backends
// (GetTickCount, or a scaled rdtsc — the latter survives warm reboots, so
// the value can be well past 2^31 even right after a "restart"). We keep
// ticks in a signed int internally, which is fine for the small deltas and
// window compares (they cancel), but a direct int->double push surfaces a
// NEGATIVE startTimeMs/endTimeMs once the tick's high bit is set.
//
// The contract is that these match `GetTime()*1000`. Script_GetTime
// (0x00515EA0) does `(double)(tick & 0xFFFFFFFF) * 0.001` — i.e. it masks
// the tick to the low 32 bits UNSIGNED, then scales. So reinterpreting our
// stored tick as uint32 reproduces GetTime()'s exact value for the whole
// range, including across the ~49.7-day 2^32 wrap (both wrap together).
// Carrying 64-bit precision here would be WRONG: it would diverge from
// GetTime() past 2^32 and break addons' (now-start)/(end-start) math.
void PushMs(void *L, int ms) {
Game::Lua::PushNumber(L, static_cast<double>(static_cast<uint32_t>(ms)));
}
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
}
@@ -173,8 +220,8 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
Game::Lua::PushString(L, name); // 1 name
Game::Lua::PushString(L, name); // 2 displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
Game::Lua::PushNumber(L, static_cast<double>(c.startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(c.endMs)); // 5 endTimeMs
PushMs(L, c.startMs); // 4 startTimeMs
PushMs(L, c.endMs); // 5 endTimeMs
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushNil(L); // 7 castID
Game::Lua::PushBool(L, false); // 8 notInterruptible
@@ -203,8 +250,8 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
Game::Lua::PushString(L, name); // 2 text / displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
if (haveTimes) {
Game::Lua::PushNumber(L, static_cast<double>(startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(endMs)); // 5 endTimeMs
PushMs(L, startMs); // 4 startTimeMs
PushMs(L, endMs); // 5 endTimeMs
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
@@ -332,7 +379,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
if (channel && castTime == 0) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
@@ -341,14 +388,19 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
// the server's duration — see ChannelStart_h.)
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
g_cast.spellID = 0; // a channel supersedes any cast
}
if (dur > 0)
StampChannel(spellID, now, now + dur);
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
// 3 s cast, then a 60 s channel). This packet is the cast phase —
// the channel begins only when the cast completes, signaled by
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
// handling so the bar shows the 3 s cast, not a premature 60 s
// channel (pfUI #9).
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
@@ -367,9 +419,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
// A channeled spell with a cast time (Mind Control) is in its CAST phase
// here — store it as a regular cast so observers see the 3 s bar. The
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
// it surfaces through the unit's broadcast +0x228 field as usual.
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
@@ -427,6 +484,151 @@ const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
StampChannel(spellID, now, now + static_cast<int>(duration));
}
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// Shared abort handling for the two sibling failure packets. Servers
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
// `SMSG_SPELL_FAILED_OTHER` from Spell::SendInterrupted, but which one a
// given core emits per abort path varies — so both handlers are co-hooked
// and both feed this. Idempotent, so the both-arrive case is harmless.
void HandleCastAborted(uint64_t guid, int spellID) {
if (guid == 0 || spellID == 0)
return;
// Remote units: invalidate the cached cast/channel so
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
e.used = false;
break;
}
}
// Local player: a server-stamped chained recast (g_castFromServer)
// never set VAR_CURRENT_CAST_SPELL, so the WorldTick VAR==0 poll can't
// clear it on interrupt — this packet is the only signal. Whether the
// caster receives their own broadcast is server-dependent; if it never
// fires, the endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() && g_cast.spellID == spellID)
g_cast.spellID = 0;
}
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's what lets us drop a remote cast the moment it dies instead
// of letting the ghost bar run to its computed end (the documented
// remote-unit limitation above). Packet body is plain (not packed):
// casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailedOther(opCode, packet);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
// (we don't read the result). This is the packet that actually stops the
// caster's cast VISUAL in the engine's own handler, so it demonstrably
// arrives for interrupts; hooking both siblings makes the eviction
// independent of which one the server chose to send.
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailure_t g_origSpellFailure = nullptr;
int __stdcall SpellFailure_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailure(opCode, packet);
}
const Game::HookAutoRegister _spellFailureHook{
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
reinterpret_cast<void **>(&g_origSpellFailure)};
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
// movement, death, animation events, local cancel — see Offsets.h). The
// original is gated on `spellID == [unit+0xC8C]` (the unit's live
// current-cast field), so a real stop only happens when the gate passes;
// we mirror the gate and evict then. This is the PRIMARY interrupt signal
// on Turtle: its core doesn't broadcast either failure packet — interrupt
// propagation to observers happens via SuperWoW, whose code calls this
// method directly (verified by return-address tracing: interrupted remote
// casts clear with a caller inside a sibling DLL, natural completions
// clear from the SPELL_GO handler). Verified in-game: Kick / Earth Shock /
// Counterspell each evict the victim's cast, while non-interrupting damage
// (pushback flinch) does NOT route through here — no false eviction.
using ClearCastingSpell_t = void(__fastcall *)(void *unit, void *edx,
int spellID, char notify,
char cleanup);
ClearCastingSpell_t g_origClearCastingSpell = nullptr;
void __fastcall ClearCastingSpell_h(void *unit, void *edx, int spellID,
char notify, char cleanup) {
if (unit != nullptr && spellID != 0) {
const int current = *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_CAST_SPELL);
if (current != 0 && current == spellID)
HandleCastAborted(Unit::Identity::GuidForObject(unit), spellID);
}
g_origClearCastingSpell(unit, edx, spellID, notify, cleanup);
}
const Game::HookAutoRegister _clearCastingHook{
Offsets::FUN_UNIT_CLEAR_CASTING_SPELL,
reinterpret_cast<void *>(&ClearCastingSpell_h),
reinterpret_cast<void **>(&g_origClearCastingSpell)};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -445,10 +647,32 @@ void OnWorldTick() {
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
// the broadcast +0x228 field for the player — it lags the channel start by
// ~1 tick, so the bar would show ~1s late.
// Detect a player CHANNEL that ended before its computed endMs — the
// summon-ritual case (UNIT_FIELD_CHANNEL_SPELL = 698, cleared when the
// summon fills), and equally an interrupted / cancelled / LoS-broken
// channel. Without this, g_channel only clears at endMs (self-expiry in
// PushChannelInfo) or when a later TIMED cast supersedes it — so an early
// end followed by an INSTANT cast (which supersedes nothing) leaves a
// ghost bar counting down to a duration that no longer reflects reality.
//
// We deliberately don't use +0x228 to START the bar — it's a broadcast
// UpdateField that lags the channel start by ~1 tick (HandleSpellStart
// stamps from the packet instead). For the STOP that lag is harmless, but a
// naive "clear when field == 0" would wipe a channel we just stamped during
// the lag window. Guard with a latch: only honor the 0 once the field has
// been seen to reflect our channel spell at least once. A channel too short
// to ever appear in the field just self-expires at endMs.
if (g_channel.spellID != 0) {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc != nullptr) {
const int chan = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (chan == g_channel.spellID)
g_channelConfirmed = true;
else if (chan == 0 && g_channelConfirmed)
g_channel.spellID = 0;
}
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
@@ -458,8 +682,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
// window; aborted casts are evicted by the ClearCastingSpell / failure-
// packet co-hooks).
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
+3 -43
View File
@@ -23,57 +23,17 @@
#include "Arg.h"
#include "Lookup.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstring>
namespace Spell::HasRange {
namespace {
// Spell.dbc RangeIndex field — mirrors `Script_GetSpellInfo`'s
// local constant. Future refactor: promote to `Offsets.h`.
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
// SpellRange.dbc record layout (verified, documented in CLAUDE.md
// under "Sub-DBC record layouts"):
// +0x00 id, +0x04 minRange (float), +0x08 maxRange (float), ...
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *spellRec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (spellRec == nullptr)
return false;
const uint32_t rangeIdx = *reinterpret_cast<const uint32_t *>(
spellRec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT,
rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
} // namespace
static int __fastcall Script_C_Spell_SpellHasRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
@@ -94,7 +54,7 @@ static int __fastcall Script_SpellHasRange(void *L) {
bookType = 1;
}
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
+58
View File
@@ -0,0 +1,58 @@
// 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_Spell.IsSpellInRange(spellIdentifier, targetUnit)` — true if the
// spell is in range of the unit, false if out of range, nil when the
// range check doesn't apply (rangeless spell, or an unresolvable
// spell / unit). `spellIdentifier` is a spellID, spell link, or the
// name of a spell in the player's spellbook; `targetUnit` is a unit
// token ("target", "mouseover", "party1", …).
//
// Thin Lua wrapper over the shared `Spell::Range::PlayerVsUnit` core
// (see spell/Range.h) — the same range test behind `C_Item.IsItemInRange`
// (via the item's on-use spell). Range-only, ignoring line of sight and
// target faction, matching retail IsSpellInRange.
#include "Arg.h"
#include "Range.h"
#include "Game.h"
namespace Spell::InRange {
namespace {
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const int result = Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellInRange",
&Script_C_Spell_IsSpellInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Spell::InRange

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