Compare commits

...
32 Commits
Author SHA1 Message Date
Brues 98b4ae14e7 totem: add GetTotemDuration(slot)
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
2026-07-20 21:27:58 -05:00
Brues 8d957eb424 totem: add TargetTotem(slot)
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
2026-07-20 21:25:34 -05:00
Brues 78618ea2cc totem: add GetTotemTimeLeft(slot)
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
2026-07-20 21:11:01 -05:00
Brues 1a4583e16e totem: fire PLAYER_TOTEM_UPDATE on slot changes
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
2026-07-20 21:04:15 -05:00
Brues a20e1206fb totem: add GetTotemInfo(slot) with a self-tracking totem subsystem
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:

- Slot from the summon spell's Spell.dbc effect (87-90 =
  SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
  the spell requires (a tool, not a consumed reagent) — resolved from the
  player's spellbook on SPELLS_CHANGED, not hardcoded.

Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).

GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
2026-07-20 20:53:01 -05:00
Brues 9f67cade87 unit: add UnitOwnerGUID(unit)
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
2026-07-20 18:35:47 -05:00
Brues 8629e13e1f gameobject: add ClosestGameObjectPosition(gameObjectID)
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.

Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.

Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
2026-07-20 18:26:46 -05:00
Brues ca9bfbab5e unit: add ClosestUnitPosition(creatureID)
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.

Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
2026-07-20 18:18:52 -05:00
Brues 8fdc767cc7 item: add Enum.ItemQuality
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
2026-07-20 17:36:47 -05:00
Brues aa31c25be5 item: add GetItemClassInfo, GetItemSubClassInfo global, Enum.ItemClass
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
  as both a global and C_Item.GetItemClassInfo. Obsolete class slots
  return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
  C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
  (Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
  post-vanilla 16..19 for parity, matching the Enum.InventoryType
  precedent in the same module.

All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
2026-07-20 17:27:28 -05:00
Brues 29aafe88f8 turtle: move combo-finisher & armor-healing custom data out of stock
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.

- aura/ComboDuration: add an AutoDanglingResolver extension point; the
  stock module keeps the dangling-row detection + scaling formula, and
  Turtle's reworked-Rip base/max values move to
  src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
  dangling-row condition stays the gate (gate on the bug, not the
  realm), preserving prior behavior exactly.

- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
  the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
  (xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
  src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
  is a generic slot that could collide on another server).

Behavior is unchanged on Turtle; both extractions are pure relocation.
2026-07-20 16:49:45 -05:00
Brues 461f15a4a6 item: derive bag family from (class, subclass) for bags with empty field
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.

Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.

Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
Brues 6245d72694 unit: make UnitInRange reach-aware
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
2026-07-20 14:52:35 -05:00
Brues bd14228ad7 event: add PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.

PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.

Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.

New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.

Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
2026-07-20 03:22:07 -05:00
Brues 5b15172894 coroutine: guard AuxResume xmoves with lua_checkstack
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.

Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
2026-07-20 00:48:28 -05:00
Brues b5470e8d3d lua: add string.reverse/strrev, math.modf, math.huge
Close the remaining 5.0->5.1 string/math stdlib gaps:

- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
  strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
  fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
  which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
  table (it's a value, not a function).

All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
2026-07-20 00:31:20 -05:00
Brues c601967fdb lua: add strjoin / strtrim / strreplace
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:

- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
  delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
  from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
  substring replace; empty find returns str unchanged. Not a stock WoW global
  (that build's compat script added it); documented as a ClassicAPI
  convenience complementing gsub.

Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
2026-07-20 00:21:59 -05:00
Brues 5c063ade73 lua: add strsplit; bind lua_checkstack
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.

Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
  GC step during a pushlstring could free the buffer we're still scanning.
  Leaving the args on the stack keeps the source GC-rooted; Lua returns the
  top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
  cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
  push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
  exhaustion (strsplit pushes an unbounded number of results). Reusable for
  any future multi-return C function.

Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
2026-07-20 00:05:24 -05:00
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
56 changed files with 4858 additions and 388 deletions
-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
-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 = {}
-13
View File
@@ -1,13 +0,0 @@
function string.match(s, pattern, init)
if s == nil then return nil end
local r1, r2, c1, c2, c3, c4, c5, c6, c7, c8, c9 =
string.find(s, pattern, init)
if r1 == nil then return nil end
if c1 ~= nil then
return c1, c2, c3, c4, c5, c6, c7, c8, c9
end
return string.sub(s, r1, r2)
end
string.gmatch = string.gfind
+21 -5
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.10)
project(ClassicAPI LANGUAGES CXX)
project(ClassicAPI LANGUAGES CXX RC)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -77,17 +77,20 @@ add_library(${PROJECT_NAME} SHARED
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(_MAJOR "${CMAKE_MATCH_1}")
set(_MINOR "${CMAKE_MATCH_2}")
set(_PATCH "${CMAKE_MATCH_3}")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${_MAJOR} * 10000 + ${_MINOR} * 100 + ${_PATCH}")
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
@@ -99,6 +102,19 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
# Stamp the PE VERSIONINFO resource from the same version components, so the
# DLL's file/product version (Explorer -> Properties -> Details) matches the
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
# untouched by the .rdata/.pdata section merges below.
set(CLASSICAPI_VER_STRING
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in"
"${CLASSICAPI_VERSION_RC}"
@ONLY)
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
+12 -8
View File
@@ -19,13 +19,13 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `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` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| [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` |
@@ -35,16 +35,17 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [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) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.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` |
| [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.GetItemClassInfo`, `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`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `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`, `math.huge`, `math.modf`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `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.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -54,18 +55,18 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [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` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [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`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `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` |
@@ -144,6 +145,7 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
@@ -166,6 +168,8 @@ when launching with `-console`), not as Lua functions. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
### Unit tokens
+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
+964 -211
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -57,7 +57,8 @@ namespace Lua {
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -100,6 +100,7 @@ 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);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -142,6 +143,7 @@ extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
extern const lua_checkstack_t CheckStack;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+121
View File
@@ -454,6 +454,15 @@ enum Offsets {
// pointer the inventory routines expect.
FUN_RESOLVE_UNIT_TOKEN = 0x00515940,
// Set the player's current target to a GUID —
// `__fastcall(uint64_t *guid)`. Validates the GUID resolves to a unit
// (typemask 8) then commits the selection (CMSG_SET_SELECTION +
// client-side target). This is what `Script_TargetUnit` (0x004899D0)
// calls after resolving its token→GUID; we call it directly with a
// GUID we already have (e.g. a totem creature). NOTE: reads `guid[0]`
// (lo) and `guid[1]` (hi) through the pointer.
FUN_TARGET_BY_GUID = 0x00489A40,
// Local-player CGObject-like global. Not the same pointer as
// ResolveUnitToken("player") returns — that one's the canonical
// CGPlayer_C used by inventory etc. This pointer's +0xC0 field
@@ -798,6 +807,19 @@ enum Offsets {
// mov edx, [eax + 0x110]
// movzx eax, byte ptr [edx + 0x7B]
OFF_UNIT_DESCRIPTOR_POWER_TYPE_BYTE = 0x7B,
// UNIT_FIELD_STAT0..4 — the fully-computed (base+item+buff) primary
// stats, broadcast in the descriptor. Order Str/Agi/Sta/Int/Spirit;
// STAT4 (Spirit) = fieldIndex 0x94 → +0x250 (from the UpdateField name
// table; cross-checked in-game: descriptor +0x250 read 173→182 when a
// +9-Spirit item was equipped). What GetStat(STAT_SPIRIT) returns
// server-side; used by GetSpellBonusHealing for the Spiritual-Guidance
// (aura 175) spirit→healing conversion.
OFF_UNIT_FIELD_STAT_SPIRIT = 0x250,
// UNIT_FIELD_RESISTANCES[0] = armor (the fully-computed total), fieldIndex
// 0x95 → +0x254 (7 resistances: armor, then the 6 magic schools). What
// GetArmor() / GetResistance(SPELL_SCHOOL_NORMAL) returns; used by
// GetSpellBonusHealing for Turtle's Ironclad (aura 199) armor→healing.
OFF_UNIT_FIELD_RESISTANCE_ARMOR = 0x254,
OFF_UNIT_FIELD_FLAGS = 0xA0,
UNIT_FLAG_PLAYER_CONTROLLED = 0x08,
// Bit 19 of UNIT_FIELD_FLAGS — `Script_UnitAffectingCombat` at
@@ -868,6 +890,27 @@ enum Offsets {
UNIT_AURA_TOTAL = 48,
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
// damage bonus. Offsets from the engine's own UpdateField name/index
// table in `.rdata` (20-byte entries `{char* name, u32 fieldIndex, u32
// count, ...}` at VA 0x83B6C8): POS = fieldIndex 0x3F5 count 7 → byte
// offset fieldIndex*4 = 0xFD4; NEG = 0x3FC → 0xFF0. Seven int32 each,
// one per school (0 physical .. 6 arcane); net bonus = POS − NEG.
//
// **Read these off `[player + OFF_CGPLAYER_INFO]` (the +0xE68 CGPlayer
// sub-struct), NOT the broadcast descriptor at +0x110.** The descriptor
// copy is never sent to the client and reads zero (verified in-game: the
// whole post-skills PLAYER descriptor region is zero on a geared L60 —
// vanilla never displayed spell power, so the server doesn't broadcast
// it). The +0xE68 struct holds the client's fully-computed value
// (gear + enchants + buffs + talents + sets baked in). Same base +
// offsets nampower's GetSpellPower uses; verified 613 generic / 647 Fire
// on a geared mage. Read by GetSpellBonusDamage in [src/spell/BonusDamage.cpp].
// (No MOD_HEALING_DONE equivalent — vanilla lacks the field entirely;
// GetSpellBonusHealing is derived instead.)
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS = 0xFD4,
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG = 0xFF0,
// Reusable "is this spell record a user-visible aura" predicate.
// `__fastcall(spellRecord*) -> bool`. Checks Spell.dbc Attributes
// (high bit of byte at +0x18 must be clear), AttributesEx bit
@@ -1804,6 +1847,16 @@ enum Offsets {
// last use"). Negative = single-use, destroyed when count hits 0.
// `GetItemCount(includeUses=true)` uses abs() for the multiplier.
OFF_DESCRIPTOR_SPELL_CHARGES_0 = 0x28,
// ITEM_FIELD_ENCHANTMENT block (fields 16..36, per the +0x48
// enchant-charges note above) — 7 enchant slots, 3 dwords each
// `{enchantID, duration, charges}`. Slot 0 = permanent enchant,
// slot 1 = temporary (weapon oils / sharpening stones). The
// enchant ID indexes SpellItemEnchantment.dbc. Block base = field
// 16 = +0x40 (field 15 FLAGS at +0x3C precedes it); slot stride
// 0x0C, enchant ID at slot+0x00. Used by spell/BonusDamage.cpp to
// fold enchant-granted spell power into GetSpellBonusDamage.
OFF_DESCRIPTOR_ENCHANTMENT_ID = 0x40,
DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE = 0x0C,
// ITEM_FIELD_DURABILITY (current) and ITEM_FIELD_MAXDURABILITY (max) live
// adjacent to each other in the descriptor as plain dwords. Verified in
// `Script_GetInventoryItemBroken` (`0x004C8590`): after resolving the
@@ -1812,6 +1865,12 @@ enum Offsets {
// no durability concept (consumables, materials) have both fields 0.
OFF_DESCRIPTOR_DURABILITY = 0xA0,
OFF_DESCRIPTOR_MAX_DURABILITY = 0xA4,
// Per-instance "readable text" id (letters / mail-scroll items carry a
// server-assigned text id here). Drives the `readable` return of
// `Script_GetContainerItemInfo` (`0x004F9670`): at `0x004F97C1` it reads
// `[descriptor+0x9C]` and, when non-zero, reports the slot as readable
// even when the static ItemStats page-text is 0. Field index 0x27.
OFF_DESCRIPTOR_READABLE_TEXT_ID = 0x9C,
// Per-item repair-cost helper used by Script_GameTooltip_SetInventoryItem
// (3rd return value). __fastcall(ecx = CGItem *) -> int copperCost.
@@ -3130,6 +3189,18 @@ enum Offsets {
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
// Player spell-knowledge writers — the two functions that mutate the
// bitmap above. Learn (`FUN_004b25b0`) sets the bit, rebuilds the
// spellbook arrays, and fires SPELLS_CHANGED; unlearn (`FUN_004b2c50`)
// clears the bit and removes the spell. Both `__fastcall`: learn is
// (uint spellID, int notify, uint replacedSpellID), unlearn is
// (uint spellID, int). Co-hooked (spell/Info.cpp) to bump
// Player::StatSignal so a talent respec invalidates GetSpellBonusHealing's
// talent-conversion cache. They fire at login (SMSG_INITIAL_SPELLS) and on
// each learn/unlearn — never per-frame, so cool hook targets.
FUN_LEARN_SPELL = 0x004B25B0,
FUN_UNLEARN_SPELL = 0x004B2C50,
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
@@ -3734,6 +3805,35 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `lua_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
// Ensures room for `n` more stack values, growing if needed; returns 0
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
// (0x800 = 2048), 1 otherwise. Verified by the `cmp reg, 0x800` overflow
// test at 0x006F2F43. Needed by any C function that pushes an unbounded
// number of results (e.g. `strsplit`) — pushing past the ~20-slot
// C-call guarantee without growing corrupts the stack.
LUA_CHECK_STACK = 0x006F2F30,
// `str_find` — the Lua 5.0 string-library `string.find` C function
// (`int __fastcall(void *L)`), entry in the strlib luaL_reg table at
// `0x00822dd0`. Returns `1` (nil pushed, no match), `2` (start, end),
// or `2 + ncaptures`. `string.match` (a Lua 5.1 addition, missing here)
// shares the exact same pattern engine — it's `find` that yields the
// captures / whole match instead of the indices — so `BaseLib::StringLib`
// calls this and transforms the result rather than reimplementing the
// matcher. Verified by decoding the function at this address.
FUN_LUA_STR_FIND = 0x007FC3B0,
// `str_gfind` — the Lua 5.0 `string.gfind` C function (strlib entry at
// `0x00822dd8`). 5.1 renamed it to `string.gmatch` with identical
// behaviour (returns a stateful iterator closure over the matches), so
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `math_fmod` — the Lua 5.0 `math.mod` C function (mathlib entry at
// `0x00822cb0`): `luaL_checknumber(1/2)` → `fmod(x, y)` → `lua_pushnumber`.
// 5.1 renamed the Lua-facing name to `math.fmod` with the identical C
// implementation, so `BaseLib::MathLib` registers `math.fmod` as a direct
// alias of this function pointer — no wrapper. Verified by decode.
FUN_LUA_MATH_FMOD = 0x007FAFD0,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
@@ -4896,8 +4996,27 @@ enum Offsets {
// Auction sell slot — holds a single CGItem GUID for the item
// currently sitting in the "sell" frame. Engine resolves it via
// a typed object lookup; pass `OBJECT_TYPE_ITEM` and the GUID.
// `Script_StartAuction` posts whatever GUID is here, so writing an
// arbitrary bag item's GUID + calling it posts that item — no cursor,
// no ClickAuctionSellItemButton. Used by `AuctionHouse::PostItem`.
VAR_AUCTION_SELL_GUID_LO = 0x00B72608,
VAR_AUCTION_SELL_GUID_HI = 0x00B7260C,
// Auctioneer NPC GUID — set while an auction house window is open;
// `Script_StartAuction` writes it as the packet target and bails if
// it's zero (so this doubles as an "AH is open" probe).
VAR_AUCTION_AUCTIONEER_GUID_LO = 0x00B725F8,
VAR_AUCTION_AUCTIONEER_GUID_HI = 0x00B725FC,
// `Script_StartAuction` (`0x004CE770`) — `int __fastcall(void *L)`;
// reads (minBid, buyout, runTime) from L[1..3], validates the sell
// slot + auctioneer + runTime, and sends CMSG_AUCTION_SELL_ITEM
// (opcode 0x256 = { auctioneerGuid, sellItemGuid, bid, buyout,
// runTime }). Reused verbatim by `AuctionHouse::PostItem` per stack.
FUN_SCRIPT_START_AUCTION = 0x004CE770,
// Valid `runTime` values `Script_StartAuction` accepts, in minutes:
// {120, 480, 1440} = 2h / 8h / 24h (vanilla's three durations). The
// modern `duration` arg (1/2/3) indexes this table.
VAR_AUCTION_DURATION_TABLE = 0x00807028,
AUCTION_DURATION_TABLE_COUNT = 3,
// `__fastcall(ECX = typeCode, EDX = unused, [stack] = guidLo,
// [stack] = guidHi, [stack] = anyInt) → CGObject *`.
// Returns NULL when no object matches or the type doesn't match.
@@ -5039,6 +5158,8 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
+427
View File
@@ -0,0 +1,427 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid,
// buyout)` — post `numStacks` auctions of `quantity` each from a single
// source stack, in one call. A ClassicAPI extension modelled on the retail
// multi-sell flow; vanilla's `StartAuction` posts exactly one item object.
//
// Why this is a tick-driven state machine (not a synchronous loop):
// vanilla's `CMSG_AUCTION_SELL_ITEM` (opcode 0x256) is
// `{ auctioneerGuid, itemGuid, bid, buyout, runTime }` — NO count field
// (WotLK added the server-side count; 1.12 has none, verified from
// `Script_StartAuction`). So the server posts the *entire* item object a
// GUID points at. To post a partial `quantity` out of a larger source, the
// client must first materialize that quantity as its own server-committed
// item object — i.e. split it into a bag slot (`CMSG_SPLIT_ITEM`) — and both
// the split and the resulting post are server-authoritative and async (the
// new stack's GUID doesn't exist client-side until `SMSG_UPDATE_OBJECT`
// lands). We drive the split→post→split→post sequence off `WorldTick`,
// keying each transition on observed bag state.
//
// Free-slot cost: a partial post needs at most ONE reused general-bag slot
// (split `quantity` off → post that stack → the post empties the slot →
// reuse it for the next split). The final stack (once the source is whittled
// to exactly `quantity`) posts the source object directly, no split. Posting
// a whole existing stack (`quantity` == the source count) needs zero free
// slots.
//
// Posting reuses the engine's `Script_StartAuction` verbatim (write the
// target stack's GUID into the sell-slot globals, push bid/buyout/runTime,
// call it) so all of its validation runs per stack — damaged-item reject,
// buyout normalization, runTime table check, auctioneer/"AH open" check.
//
// Events (retail multi-sell names, fired via Event::Custom):
// AUCTION_MULTISELL_START — job accepted, posting begins
// AUCTION_MULTISELL_UPDATE(done, total) — after each stack lands
// AUCTION_MULTISELL_FAILURE — aborted (source changed, no slot,
// AH closed, or a step timed out)
//
// Limitations (v1): source is the single stack at `itemLocation` (not
// aggregated across bags); temp splits use general-purpose bags only, so
// family-restricted items (arrows/shards in specialty bags) aren't
// supported; one job at a time.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
// (disconnect, server refusal we can't see) rather than normal latency.
constexpr int kMaxWaitTicks = 600;
enum class Phase { Idle, SplitWait, PostWait };
struct Job {
bool active;
int srcBag, srcSlot; // source stack (shrinks in place as we split off it)
int itemID; // guards against the source slot changing under us
int quantity;
int totalStacks;
int postedStacks;
int bid, buyout, runTime;
Phase phase;
int workBag, workSlot; // slot currently being split-into / posted-from
uint64_t postGuid; // GUID we posted — detect it leaving bags
int waitTicks;
};
Job g_job{};
// --- small CGItem / cache readers ---------------------------------------
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int CGItemCount(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
struct SlotInfo {
const uint8_t *item; // null = empty slot
int itemID;
int count;
uint64_t guid;
};
SlotInfo ReadSlot(void *L, int bag, int slot) {
SlotInfo r{nullptr, 0, 0, 0};
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr)
return r;
r.item = item;
r.itemID = Item::ID::FromCGItem(item);
r.count = CGItemCount(item);
r.guid = CGItemGuid(item);
return r;
}
// --- auction-house globals ----------------------------------------------
uint64_t AuctioneerGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
bool AHOpen() { return AuctioneerGuid() != 0; }
void SetSellGuid(uint64_t guid) {
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
static_cast<uint32_t>(guid);
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
static_cast<uint32_t>(guid >> 32);
}
// Point the sell slot at `guid` and fire the engine's own StartAuction for
// this bid/buyout/runTime. StartAuction reads its args from L[1..3]; we own
// the stack at frame-tail, so set top to 0, push the three, call, reset.
void PostStack(void *L, uint64_t guid) {
SetSellGuid(guid);
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(g_job.bid));
Game::Lua::PushNumber(L, static_cast<double>(g_job.buyout));
Game::Lua::PushNumber(L, static_cast<double>(g_job.runTime));
auto fn = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_START_AUCTION);
fn(L);
Game::Lua::SetTop(L, 0);
}
// --- free-slot discovery (general-purpose bags only) --------------------
bool BagIsGeneral(int bag) {
if (bag == 0)
return true; // backpack is always general
if (bag < 1 || bag > 4)
return false;
const uint8_t *bagItem =
Item::Location::ResolveEquipmentSlot(Offsets::INVSLOT_BAG1 + bag - 1);
if (bagItem == nullptr)
return false; // no bag equipped there
const int id = Item::ID::FromCGItem(bagItem);
if (id == 0)
return false;
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
}
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
for (int bag = 0; bag <= 4; ++bag) {
if (!BagIsGeneral(bag))
continue;
const int n = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= n; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr) {
*outBag = bag;
*outSlot = slot;
return true;
}
}
}
return false;
}
// --- state machine ------------------------------------------------------
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
void Tick() {
if (!g_job.active)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int savedTop = Game::Lua::GetTop(L);
if (!AHOpen()) { // window closed / walked away mid-job
Finish(false);
Game::Lua::SetTop(L, savedTop);
return;
}
switch (g_job.phase) {
case Phase::Idle: {
if (g_job.postedStacks >= g_job.totalStacks) {
Finish(true);
break;
}
const SlotInfo s = ReadSlot(L, g_job.srcBag, g_job.srcSlot);
if (s.item == nullptr || s.itemID != g_job.itemID || s.count < g_job.quantity) {
Finish(false); // source moved / consumed / insufficient
break;
}
if (s.count == g_job.quantity) {
// Final exact stack — post the source object directly, no split.
g_job.workBag = g_job.srcBag;
g_job.workSlot = g_job.srcSlot;
g_job.postGuid = s.guid;
PostStack(L, s.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs)) {
Finish(false); // no room to materialize the stack
break;
}
if (!Item::Swap::MoveCount(L, g_job.srcBag, g_job.srcSlot, fb, fs,
g_job.quantity)) {
Finish(false);
break;
}
g_job.workBag = fb;
g_job.workSlot = fs;
g_job.phase = Phase::SplitWait;
g_job.waitTicks = 0;
}
break;
}
case Phase::SplitWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
if (w.item != nullptr && w.itemID == g_job.itemID && w.count == g_job.quantity) {
g_job.postGuid = w.guid;
PostStack(L, w.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
case Phase::PostWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
}
Game::Lua::SetTop(L, savedTop);
}
const Tick::WorldTick::AutoSubscribe _tick{&Tick};
// --- Lua entry ----------------------------------------------------------
// Reads `loc.field` as an int. False if missing/non-numeric. Leaves the
// stack as found. (Same shape as Item::Location's file-static helper.)
bool ReadIntField(void *L, int locIdx, const char *field, int *out) {
Game::Lua::PushString(L, field);
Game::Lua::GetTable(L, locIdx);
const bool ok = Game::Lua::IsNumber(L, -1);
if (ok)
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
Game::Lua::SetTop(L, -2);
return ok;
}
// Map the modern `duration` arg to a valid engine runTime (minutes).
// Accepts the 1/2/3 index form and the raw {120,480,1440} value form.
// Returns 0 for anything else.
int MapDuration(int d) {
auto *table = reinterpret_cast<const uint32_t *>(Offsets::VAR_AUCTION_DURATION_TABLE);
if (d >= 1 && d <= Offsets::AUCTION_DURATION_TABLE_COUNT)
return static_cast<int>(table[d - 1]);
for (int i = 0; i < Offsets::AUCTION_DURATION_TABLE_COUNT; ++i)
if (static_cast<int>(table[i]) == d)
return d;
return 0;
}
// Push (false, reason) and return 2 — the immediate-rejection shape.
int Reject(void *L, const char *reason) {
Game::Lua::PushBool(L, false);
Game::Lua::PushString(L, reason);
return 2;
}
constexpr int kLuaTable = 5; // LUA_TTABLE (no named constant in Game::Lua)
int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
if (Game::Lua::Type(L, 1) != kLuaTable) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 4) || !Game::Lua::IsNumber(L, 5)) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
int bagID = 0, slotIndex = 0;
if (!ReadIntField(L, 1, "bagID", &bagID) ||
!ReadIntField(L, 1, "slotIndex", &slotIndex))
return Reject(L, "itemLocation must be a { bagID, slotIndex } table");
const int duration = static_cast<int>(Game::Lua::ToNumber(L, 2));
const int quantity = static_cast<int>(Game::Lua::ToNumber(L, 3));
const int numStacks = static_cast<int>(Game::Lua::ToNumber(L, 4));
const int bid = static_cast<int>(Game::Lua::ToNumber(L, 5));
const int buyout = Game::Lua::IsNumber(L, 6) ? static_cast<int>(Game::Lua::ToNumber(L, 6)) : 0;
const int runTime = MapDuration(duration);
if (runTime == 0)
return Reject(L, "invalid duration (use 1/2/3 or 120/480/1440)");
if (quantity < 1 || quantity > 255)
return Reject(L, "quantity must be 1..255");
if (numStacks < 1)
return Reject(L, "numStacks must be >= 1");
if (bid < 1)
return Reject(L, "bid must be >= 1");
if (!AHOpen())
return Reject(L, "auction house is not open");
if (g_job.active)
return Reject(L, "a multi-post is already in progress");
const SlotInfo s = ReadSlot(L, bagID, slotIndex);
if (s.item == nullptr)
return Reject(L, "source slot is empty");
const long need = static_cast<long>(quantity) * numStacks;
if (static_cast<long>(s.count) < need)
return Reject(L, "not enough items for numStacks x quantity");
// A partial post (source larger than one stack) needs one reusable
// general-bag slot to materialize each split. A whole-stack post
// (count == quantity, numStacks == 1) needs none.
if (s.count > quantity) {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs))
return Reject(L, "need at least one free general bag slot to split");
}
g_job = Job{};
g_job.active = true;
g_job.srcBag = bagID;
g_job.srcSlot = slotIndex;
g_job.itemID = s.itemID;
g_job.quantity = quantity;
g_job.totalStacks = numStacks;
g_job.postedStacks = 0;
g_job.bid = bid;
g_job.buyout = buyout;
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AuctionHouse", "PostItem",
&Script_C_AuctionHouse_PostItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AuctionHouse::PostItem
+21 -28
View File
@@ -132,30 +132,19 @@ const Override *FindOverride(uint32_t spellId) {
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},
};
// Resolver chain for dangling-duration-row spells (client DBC missing the
// row). The KNOWN VALUES themselves are server-custom data and live in
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
// reworked Rip); this stock module only owns the mechanism and the
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
AutoDanglingResolver *g_danglingResolvers = nullptr;
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
r = r->next)
if (r->fn(spellId, baseMs, maxMs))
return true;
return false;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
@@ -203,6 +192,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
: fn(f), next(g_danglingResolvers) {
g_danglingResolvers = this;
}
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
@@ -227,12 +221,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
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)
// spell. Defer to a registered resolver (the known values are
// server-custom data owned by a platform module, e.g.
// src/turtle for Turtle's reworked Rip).
if (!ResolveDangling(spellId, &baseMs, &maxMs))
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
+18
View File
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
// Extension point for finishers whose SpellDuration row DANGLES — a
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
// condition (after any Lua-registered override), so the activation gate is
// the client-data bug itself, not a server fingerprint — the deliberate
// design choice (a realm-sniffing approach was rejected). Server-custom
// data (e.g. Turtle's reworked Rip) registers its values here instead of
// living in this stock module: declare a file-scope
// `static const Aura::ComboDuration::AutoDanglingResolver`.
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
int32_t *maxMs);
struct AutoDanglingResolver {
explicit AutoDanglingResolver(DanglingResolver fn);
DanglingResolver fn;
AutoDanglingResolver *next;
};
} // namespace Aura::ComboDuration
+19
View File
@@ -20,8 +20,10 @@
#include "Game.h"
#include "Offsets.h"
#include "net/PacketReader.h"
#include "player/StatSignal.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -229,6 +231,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (caster == 0 || spellId == 0)
return;
// Feed the totem tracker BEFORE the aura gate below — a totem summon
// applies no aura, so it would otherwise be dropped. Player casts only.
if (caster == Unit::Identity::PlayerGuid())
Totem::Tracker::OnPlayerSpellGo(spellId);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
@@ -288,6 +295,15 @@ int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
// player — buffs/debuffs move GetSpellBonusHealing's flat and Spirit/Armor
// terms, so its lazy cache must invalidate. Guarded on the player object so
// other units' aura churn (combat) doesn't needlessly invalidate it.
void NotifyIfPlayer(void *unit) {
if (static_cast<const uint8_t *>(unit) == Unit::Identity::PlayerObject())
Player::StatSignal::Notify();
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
@@ -297,6 +313,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraAdded{
@@ -317,6 +334,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
KindForSlot(slot));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraStacks{
@@ -346,6 +364,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{
+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/>.
// 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`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
//
// `math.fmod` 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 it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
#include "Game.h"
#include "Offsets.h"
#include <cmath>
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);
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
// truncated toward zero and the fractional part carrying `x`'s sign
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
int __fastcall Script_math_modf(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: math.modf(x)");
return 0; // unreachable
}
double integral = 0.0;
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
Game::Lua::PushNumber(L, integral);
Game::Lua::PushNumber(L, fractional);
return 2;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
void *L = Game::Lua::State();
if (L != nullptr) {
Game::Lua::PushString(L, "math");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
Game::Lua::SetTop(L, -2); // pop [math]
} else {
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
}
}
}
// 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
+282
View File
@@ -0,0 +1,282 @@
// 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/>.
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
//
// The two pattern `string.*` ones reuse machinery 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.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// 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>
#include <cstring>
#include <string>
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);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
for (const char *s = sep; *s != '\0'; ++s) {
if (*p == *s) {
isSep = true;
break;
}
}
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
// piece is treated as an empty string rather than erroring, so the delimiter
// placement stays predictable. `strjoin(",")` with no pieces returns "".
int __fastcall Script_strjoin(void *L) {
const char *delim = Game::Lua::ToString(L, 1);
if (delim == nullptr) {
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
return 0; // unreachable
}
const unsigned delimLen = Game::Lua::StrLen(L, 1);
const int top = Game::Lua::GetTop(L);
std::string out; // no Error() past this point — a longjmp would leak it
for (int i = 2; i <= top; ++i) {
if (i > 2)
out.append(delim, delimLen);
const char *piece = Game::Lua::ToString(L, i);
if (piece != nullptr)
out.append(piece, Game::Lua::StrLen(L, i));
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
return 1;
}
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
// both ends of `str` (default: whitespace). `chars` is a literal set of
// characters, not a pattern. Standard WoW global. Two-ended scan — no
// allocation, embedded NULs preserved (bounds come from the Lua length).
int __fastcall Script_strtrim(void *L) {
const char *str = Game::Lua::ToString(L, 1);
if (str == nullptr) {
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
: " \t\n\v\f\r";
bool trim[256] = {false};
for (const char *c = chars; *c != '\0'; ++c)
trim[static_cast<unsigned char>(*c)] = true;
unsigned start = 0, end = len;
while (start < end && trim[static_cast<unsigned char>(str[start])])
++start;
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
--end;
Game::Lua::PushLString(L, str + start, end - start);
return 1;
}
// `strreplace(str, find, replace)` — replace every occurrence of the literal
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
// needs no magic-character escaping. Empty `find` returns `str` unchanged
// (count 0) rather than looping forever.
int __fastcall Script_strreplace(void *L) {
const char *str = Game::Lua::ToString(L, 1);
const char *find = Game::Lua::ToString(L, 2);
const char *repl = Game::Lua::ToString(L, 3);
if (str == nullptr || find == nullptr || repl == nullptr) {
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
return 0; // unreachable
}
const unsigned strLen = Game::Lua::StrLen(L, 1);
const unsigned findLen = Game::Lua::StrLen(L, 2);
const unsigned replLen = Game::Lua::StrLen(L, 3);
if (findLen == 0) { // nothing to match — return input unchanged
Game::Lua::PushLString(L, str, strLen);
Game::Lua::PushNumber(L, 0.0);
return 2;
}
std::string out; // no Error() past this point
int count = 0;
unsigned i = 0;
while (i < strLen) {
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
out.append(repl, replLen);
i += findLen;
++count;
} else {
out.push_back(str[i]);
++i;
}
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 2;
}
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
// Lua length).
int __fastcall Script_string_reverse(void *L) {
const char *s = Game::Lua::ToString(L, 1);
if (s == nullptr) {
Game::Lua::Error(L, "Usage: string.reverse(s)");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
std::string out(len, '\0'); // no Error() past this point
for (unsigned i = 0; i < len; ++i)
out[i] = s[len - 1 - i];
Game::Lua::PushLString(L, out.data(), len);
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// 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
+21 -22
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
@@ -73,15 +74,14 @@ BagInfo ResolveBagInfo(int bagID) {
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
// we inline the one-liner here to avoid a header dance for a
// single-instruction conversion.
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
@@ -106,14 +106,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
// Light Quiver actually carries a non-zero family for bags themselves
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
// vanilla-era server data doesn't populate the field. **Individual
// items** (arrows, bullets, shards, herbs) reliably return their
// family, so addons that auto-route loot work correctly via
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -139,12 +138,12 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
// 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.
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
+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
+8
View File
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
: "cannot resume non-suspended coroutine");
return -1;
}
if (Game::Lua::CheckStack(co, nargs) == 0) {
Game::Lua::PushString(L, "too many arguments to resume coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres) == 0) {
Game::Lua::PushString(L, "too many results to resume coroutine");
return -1;
}
Game::Lua::XMove(co, L, nres);
return nres;
}
+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);
};
+109
View File
@@ -0,0 +1,109 @@
// 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_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
// backported. Vanilla fires PEW with no args; backported addons expect the
// two booleans to distinguish a fresh login from a `/reload` from an
// instance/zone transition.
//
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
// two-boolean payload. Both fire paths read the same frame chain off the
// event's table slot, so the frames registered for PEW receive the args
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
// `if isInitialLogin then …` reads naturally.
//
// Flags, from signals we already produce — no PEW-specific engine hook:
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
// boot (initial launch + each logout return), so a set latch means "the
// next enter-world is a fresh login". The first PEW consumes it.
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
// "script re-init happened and it wasn't the fresh login" == a reload.
// - Instance/zone transition rebuilds neither, so both latches are clear →
// both false, matching retail.
#include "Game.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
namespace Event::EnteringWorld {
namespace {
// Set on glue-screen boot; a pending latch means the next enter-world is a
// fresh character login (from the login/char-select screen).
bool g_atGlue = false;
// Set when the in-game Lua state is (re)built (login or /reload).
bool g_scriptReloaded = false;
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
// in-game init (the table is rebuilt, though engine slots are stable) and
// re-resolved lazily at fire time.
int g_pewId = -1;
int PewId() {
if (g_pewId < 0)
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
return g_pewId;
}
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
// suppress the engine's no-arg fire; everything else passes through.
bool Intercept(int eventID) {
const int pew = PewId();
if (pew < 0 || eventID != pew)
return false;
const bool isInitialLogin = g_atGlue;
g_atGlue = false;
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
g_scriptReloaded = false;
// Re-fire through the variadic dispatcher with the two booleans. true → 1
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
// tail-jumps to pushnil).
auto *const kNil = static_cast<const char *>(nullptr);
if (isInitialLogin && isReloadingUi)
Event::Custom::Fire(pew, "%d%d", 1, 1);
else if (isInitialLogin)
Event::Custom::Fire(pew, "%d%s", 1, kNil);
else if (isReloadingUi)
Event::Custom::Fire(pew, "%s%d", kNil, 1);
else
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
return true; // handled — suppress the original no-arg PEW fire
}
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
// zone transition). Also re-arm the PEW slot lookup — the event table was
// rebuilt.
void OnInGameInit() {
g_scriptReloaded = true;
g_pewId = -1;
}
// Glue-screen boot: we're at the login/char-select screen, so the next
// enter-world will be a fresh login.
void OnGlueInit() { g_atGlue = true; }
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
} // namespace
} // namespace Event::EnteringWorld
+49
View File
@@ -0,0 +1,49 @@
// 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 "SignalHook.h"
#include "Game.h"
#include "Offsets.h"
namespace Event::SignalHook {
namespace {
// Chained at static-init time by each `AutoSubscribe` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
// so the head is valid when subscribers link in.
AutoSubscribe *g_head = nullptr;
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
// long-standing shape with a dummy EDX so the trampoline call matches the
// register layout, and forward `0` for it (unused by the callee).
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
FireNoArgs_t g_orig = nullptr;
void __fastcall Hook(int eventID, int /*edx*/) {
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
if (s->fn(eventID))
return; // a subscriber handled it — suppress the original fire
g_orig(eventID, 0);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
reinterpret_cast<void **>(&g_orig)};
} // namespace
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
} // namespace Event::SignalHook
+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/>.
#pragma once
// Shared interceptor for the engine's no-arg event dispatcher
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
//
// MinHook allows only ONE hook per target address, and more than one feature
// needs to intercept this dispatcher (loot silent-scan suppression, the
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
// per-frame tick — this module owns the single hook and fans out to
// subscribers. Declare a file-scope `static const
// Event::SignalHook::AutoSubscribe` in your module.
//
// An interceptor returns `true` when it has fully handled `eventID` and the
// engine's original no-arg fire must be SUPPRESSED — either because it
// swallowed the event (loot) or re-fired it a different way (the PEW payload
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
// `false` lets the event fall through to the next subscriber and ultimately
// the original. The FIRST subscriber to return `true` wins and the original
// is skipped, so subscribers must claim disjoint event ids.
namespace Event::SignalHook {
using Interceptor = bool (*)(int eventID);
struct AutoSubscribe {
explicit AutoSubscribe(Interceptor fn);
Interceptor fn;
AutoSubscribe *next;
};
} // namespace Event::SignalHook
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestGameObjectPosition(gameObjectID)` → `xPos, yPos, distance` — the
// world position of the nearest game object with the given
// game-object-template ID, and its distance from the player in yards.
// Returns nothing when none is found (the `MayReturnNothing` contract).
//
// The game-object analog of `ClosestUnitPosition`; same shared scan
// (Object::ClosestByEntry), just filtering on the GameObject GUID type
// (`0xF110`) and `TYPEMASK_GAMEOBJECT`. Same retail-vs-vanilla caveat:
// this returns the nearest CURRENTLY-VISIBLE object of that entry rather
// than reading retail's static starting-zone database (which vanilla
// doesn't have).
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace GameObject::ClosestPosition {
namespace {
int __fastcall Script_ClosestGameObjectPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestGameObjectPosition(gameObjectID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_GAMEOBJECT, Guid::Type::GameObject, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestGameObjectPosition",
&Script_ClosestGameObjectPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace GameObject::ClosestPosition
+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/>.
#include "item/BagFamily.h"
#include "Offsets.h"
namespace Item::BagFamily {
namespace {
// Chained at static-init by each `AutoResolver` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic
// init, so the head is valid when resolvers link in.
AutoResolver *g_resolvers = nullptr;
// Stock vanilla bags/quivers that shipped with an empty `m_bagFamily`
// field, recovered from (class, subclass). Correspondence cross-referenced
// by name between ItemSubClass.dbc and ItemBagFamily.dbc. Everything else
// in stock vanilla either carries the field directly or has no family, so
// it isn't listed — server-custom bags come in through the resolver chain.
uint32_t StockSubclassToId(uint32_t itemClass, uint32_t subclass) {
if (itemClass == kItemClassContainer) {
switch (subclass) {
case 1: return 3; // Soul Bag → Soul Shards
case 2: return 6; // Herb Bag → Herbs
case 3: return 7; // Enchanting Bag → Enchanting Supplies
case 4: return 8; // Engineering Bag → Engineering Supplies
default: return 0;
}
}
if (itemClass == kItemClassQuiver) {
switch (subclass) {
case 2: return 1; // Quiver → Arrows
case 3: return 2; // Ammo Pouch → Bullets
default: return 0;
}
}
return 0;
}
} // namespace
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
g_resolvers = this;
}
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass) {
if (const uint32_t id = StockSubclassToId(itemClass, subclass))
return id;
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
if (const uint32_t id = r->fn(itemClass, subclass))
return id;
return 0;
}
uint32_t BitmaskForRecord(const uint8_t *record) {
uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
if (rawId == 0) {
const uint32_t cls = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t sub = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
rawId = SubclassToId(cls, sub);
}
return IdToBitmask(rawId);
}
} // namespace Item::BagFamily
+72
View File
@@ -0,0 +1,72 @@
// 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>
// Shared BagFamily derivation for a cached `ItemStats_C` record. Used by
// `C_Item.GetItemFamily` and `C_Container.GetContainerNumFreeSlots` /
// `CalculateTotalNumberOfFreeBagSlots` so the family a bag reports matches
// the family the items it holds report.
//
// This module knows only STOCK vanilla data. Server-custom container
// families (e.g. Turtle's cooking/gathering bags) plug in through the
// `AutoResolver` extension point below, so non-stock hardcoded data lives
// in its own module (see `src/turtle/`) rather than here.
namespace Item::BagFamily {
// Item.dbc `m_class` values. Containers hold the profession/soul bags;
// Quivers hold arrows (subclass 2) and bullets/ammo pouches (subclass 3).
// See ItemClass.dbc.
constexpr uint32_t kItemClassContainer = 1;
constexpr uint32_t kItemClassQuiver = 11;
// Convert 1.12's raw 1-based `ItemBagFamily.dbc` ID to the modern bitmask
// encoding (`1 << (id - 1)`); `0` stays `0`. Vanilla stores the raw ID
// (`arrow=1, bullet=2, soul shard=3, herb=6, …`); Wrath+ flipped the same
// field to the bitmask form, which is what backported addons expect.
inline uint32_t IdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// (class, subclass) → `ItemBagFamily.dbc` raw ID, for bags/quivers whose
// `m_bagFamily` field shipped empty. Covers the stock vanilla profession
// bags (Container class: Soul/Herb/Enchanting/Engineering) and quivers
// (Quiver class: Quiver→arrows, Ammo Pouch→bullets); for anything else it
// consults the registered custom resolvers (see `AutoResolver`) before
// giving up with `0`. Keyed on class because subclass numbers repeat
// across classes (Container subclass 2 = Herb Bag, Quiver subclass 2 =
// Quiver). Returns a raw family ID, not a bitmask.
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass);
// BagFamily bitmask for a cached `ItemStats_C` record: the item's
// `m_bagFamily` field if populated, otherwise — for a container whose
// field is empty — derived from the container subclass. Pass a non-null
// record.
uint32_t BitmaskForRecord(const uint8_t *record);
// Extension point for server-custom bag families. A resolver maps a
// (class, subclass) pair to an `ItemBagFamily.dbc` raw ID, or returns `0`
// if it doesn't recognize it. Consulted by `SubclassToId` only after the
// stock table misses. Declare a file-scope
// `static const Item::BagFamily::AutoResolver` in a platform module to
// register one at static-init time.
using Resolver = uint32_t (*)(uint32_t itemClass, uint32_t subclass);
struct AutoResolver {
explicit AutoResolver(Resolver fn);
Resolver fn;
AutoResolver *next;
};
} // namespace Item::BagFamily
+11
View File
@@ -11,6 +11,8 @@
// 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/Count.h"
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
@@ -251,6 +253,15 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
} // namespace
int InInventory(void *L, int itemID) {
if (itemID <= 0)
return 0;
int total = CountEquipped(itemID, /*includeUses*/ false);
for (int bag = 0; bag <= 4; bag++)
total += CountInBag(L, bag, itemID, /*includeUses*/ false);
return total;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemCount",
&Script_C_Item_GetItemCount);
+25
View File
@@ -0,0 +1,25 @@
// 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 Item::Count {
// Total plain stack count of `itemID` across the player's equipped slots
// and bags 0..4 (no bank, no charge multiplier) — the "carried on me"
// count. Needs the Lua state `L`: the bag walk uses the engine's slot
// packer, which reads and clears the Lua stack, so the caller must have
// already consumed any stack arguments it still needs before calling.
int InInventory(void *L, int itemID);
} // namespace Item::Count
+96 -18
View File
@@ -15,8 +15,10 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/BagFamily.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -191,17 +193,6 @@ static int __fastcall Script_C_Item_GetItemIconByID(void *L) {
return PushIconForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// Convert 1.12's raw `BagFamily` ID to the modern bitmask encoding.
// Vanilla stores `arrow=1, bullet=2, soul shard=3, herb=6, ...`; Wrath
// flipped to `1 << (ID-1)` (`arrow=0x1, bullet=0x2, soul shard=0x4,
// herb=0x20, ...`) and addons backported from Wrath+ expect the
// bitmask. We always surface the modern shape so callers don't have to
// branch on client version. See `Offsets::OFF_ITEMSTATS_BAG_FAMILY`
// for the empirical verification.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// `C_Item.GetItemFamily(item)` — returns the BagFamily bitmask for an
// item (modern encoding: `1 << (ID-1)`), or `nil` if the item isn't
// in the cache. Used by auto-routing addons that decide which
@@ -226,10 +217,14 @@ uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
// but has family 0 (general-purpose)". Both are safe to treat as "no
// preference" but keeping them distinct helps debugging.
//
// 1.12 vanilla server data sparsely populates the field for **bags**
// themselves (only quivers seem to carry it on Turtle WoW). Individual
// loot items do carry the field reliably (arrows, bullets, shards,
// herbs all return their proper raw IDs).
// 1.12 vanilla server data leaves the `m_bagFamily` field empty on the
// **bags/quivers** themselves — Soul/Herb/Enchanting/Engineering bags AND
// quivers/ammo pouches all read 0. Individual loot items do carry the
// field reliably (arrows, bullets, shards, herbs return their proper raw
// IDs). For a bag whose field is empty we recover the family from its
// class + subclass (the same signal the "Soul Bag" tooltip line uses), so
// a Felcloth Bag reports the Soul Shard family (0x4), a quiver 0x1, an
// ammo pouch 0x2 — see `Item::BagFamily`.
static int __fastcall Script_C_Item_GetItemFamily(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
@@ -239,13 +234,96 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::PushNumber(L, static_cast<double>(BagFamilyIdToBitmask(rawId)));
// Family field if populated, else derived from the container subclass
// (Turtle leaves bags' m_bagFamily at 0 — see Item::BagFamily).
Game::Lua::PushNumber(
L, static_cast<double>(Item::BagFamily::BitmaskForRecord(record)));
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);
+105 -17
View File
@@ -41,7 +41,16 @@
// 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).
// Bank and equipment are out of scope, matching retail.
//
// 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
@@ -67,17 +76,19 @@ namespace {
constexpr const char *kEvtNewItems = "BAG_NEW_ITEMS_UPDATED";
const Event::Custom::AutoReserve _r{kEvtNewItems};
// Bags cap at backpack (16) + four equipped bags; even the largest Turtle
// bags stay well under this. Overflow silently drops — a flagged-new item
// past the cap just won't glow, never a crash.
constexpr int kMaxItems = 256;
// 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 bag-resident GUIDs at the last scan
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;
@@ -167,6 +178,81 @@ int ScanBags(const uint8_t *player, uint64_t *out) {
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), "");
}
@@ -204,9 +290,11 @@ void OnTick() {
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently in the bags becomes the baseline; nothing owned at
// login is "new". Any change up to this point is intentionally absorbed.
// 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;
}
@@ -224,25 +312,25 @@ void OnBagChanged() {
if (player == nullptr)
return;
uint64_t current[kMaxItems];
const int currentCount = ScanBags(player, current);
uint64_t currentBags[kMaxItems];
const int bagCount = ScanBags(player, currentBags);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, banked).
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
for (int i = 0; i < g_newCount;) {
if (!Contains(current, currentCount, g_new[i])) {
if (!Contains(currentBags, bagCount, g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
// Flag anything not present at the previous scan.
for (int i = 0; i < currentCount; ++i) {
if (!Contains(g_seen, g_seenCount, current[i]))
changed |= AddUnique(g_new, &g_newCount, current[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(current, currentCount);
CopyToSeen(currentBags, bagCount);
AppendNonBagOwned(g_seen, &g_seenCount);
if (changed)
FireChanged();
+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);
+89 -1
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
@@ -25,6 +26,18 @@ int LocaleIndex() {
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// Localized ItemClass.dbc name for a class ID (records indexed by ID, the
// standard DBC shape — same lookup `Script_GetItemInfo` uses). Returns ""
// for an unknown class. Obsolete slots keep the client's literal string
// (classID 3 → "Jewelry(OBSOLETE)", 8 → "Generic(OBSOLETE)", …) — that's
// the real vanilla data; Enum.ItemClass supplies the modern key names.
const char *ClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(Offsets::VAR_ITEMCLASS_RECORDS,
Offsets::VAR_ITEMCLASS_COUNT, classID,
Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// Linear-scan ItemSubClass.dbc for the (classID, subClassID) compound-key
// row — same scan Item::GetData uses. Returns the record or nullptr.
const uint8_t *FindSubClass(uint32_t classID, uint32_t subClassID) {
@@ -72,8 +85,27 @@ const char *InvTypeKey(int invType) {
return (s != nullptr && s[0] != '\0') ? s : "";
}
// `GetItemClassInfo(classID)` → `className` (localized). `nil` for an
// unknown class. Backport of the modern global (also exposed as
// `C_Item.GetItemClassInfo`).
int __fastcall Script_GetItemClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItemClassInfo(classID)");
return 0;
}
const char *name = ClassName(static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
if (name[0] == '\0') {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_Item.GetItemSubClassInfo(classID, subClassID)` →
// `subClassName, subClassUsesInvType`.
// `subClassName, subClassUsesInvType`. Also exposed as the global
// `GetItemSubClassInfo(classID, subClassID)` → `name, isArmorType`, the
// same two values (the second is true for the armor material subclasses).
//
// `subClassName` is the localized ItemSubClass.dbc name (verbose form, e.g.
// "One-Handed Swords"; falls back to the short form for subclasses that
@@ -184,7 +216,57 @@ constexpr Game::Lua::EnumIntegerEntry kInventoryTypeEntries[] = {
{"IndexEquipablespellWeaponType", 34},
};
// `Enum.ItemClass` — the modern item-class enum. Values 0..15 are the
// classes vanilla items actually report (the numeric IDs match retail; the
// obsolete slots 3/8/10/14 keep their modern key names — Gem /
// ItemEnhancement / CurrencyTokenObsolete / PermanentObsolete — even
// though ItemClass.dbc labels them "…(OBSOLETE)"). 16..19 are post-vanilla
// and never appear in 1.12 item data, included so backport code that
// references those keys resolves (comparisons just never match).
constexpr Game::Lua::EnumIntegerEntry kItemClassEntries[] = {
{"Consumable", 0},
{"Container", 1},
{"Weapon", 2},
{"Gem", 3},
{"Armor", 4},
{"Reagent", 5},
{"Projectile", 6},
{"Tradegoods", 7},
{"ItemEnhancement", 8},
{"Recipe", 9},
{"CurrencyTokenObsolete", 10},
{"Quiver", 11},
{"Questitem", 12},
{"Key", 13},
{"PermanentObsolete", 14},
{"Miscellaneous", 15},
{"Glyph", 16},
{"Battlepet", 17},
{"WoWToken", 18},
{"Profession", 19},
};
// `Enum.ItemQuality` — the numeric `quality` reported by `GetItemInfo` /
// `C_Item.GetItemQuality`. Values 0..6 are all vanilla ever uses; the
// post-vanilla Heirloom (7) and WoWToken (8) tiers are omitted (no such
// items exist on 1.12).
constexpr Game::Lua::EnumIntegerEntry kItemQualityEntries[] = {
{"Poor", 0},
{"Common", 1},
{"Uncommon", 2},
{"Rare", 3},
{"Epic", 4},
{"Legendary", 5},
{"Artifact", 6},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterGlobalFunction("GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInventorySlotKey",
@@ -194,6 +276,12 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "InventoryType", kInventoryTypeEntries,
sizeof(kInventoryTypeEntries) / sizeof(kInventoryTypeEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemClass", kItemClassEntries,
sizeof(kItemClassEntries) / sizeof(kItemClassEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemQuality", kItemQualityEntries,
sizeof(kItemQualityEntries) / sizeof(kItemQualityEntries[0]));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+10 -14
View File
@@ -27,6 +27,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../event/Custom.h"
#include "../event/SignalHook.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
@@ -115,9 +116,6 @@ using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
int /*edx unused*/);
struct C3Vector {
float x, y, z;
};
@@ -256,7 +254,6 @@ void StartLoot(uint64_t guid) {
// === Hooks ===
LootController_t s_lootController_o = nullptr;
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
// actively interleaving open/close cycles, signaled by
@@ -265,12 +262,14 @@ FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `SendCloseLoot` that follows, then dropped before
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
// event fire — and normal play's loot events — pass through.
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
if (s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
return;
}
s_fireEventNoArgs_o(eventID, 0);
//
// Runs as an interceptor on the shared no-arg event hook
// (`Event::SignalHook`) rather than owning the hook directly, so it can
// coexist with other features that intercept the same dispatcher (e.g. the
// PLAYER_ENTERING_WORLD payload). Return true to swallow.
bool SuppressLootEvents(int eventID) {
return s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED);
}
// Intercepts the engine's loot-window state controller. Two paths:
@@ -336,10 +335,7 @@ const Game::HookAutoRegister _hookController{
reinterpret_cast<void *>(&LootController_h),
reinterpret_cast<void **>(&s_lootController_o)};
const Game::HookAutoRegister _hookFireEvent{
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<void *>(&FireEventNoArgs_h),
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (timeout only) ===
+105
View File
@@ -0,0 +1,105 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 "object/ClosestByEntry.h"
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Object::ClosestByEntry {
namespace {
// `ClntObjMgrEnumVisibleObjects(cb, ctx)` — walks the visible-object
// table; the callback's dummy EDX slot absorbs the second __fastcall
// register so the trailing `uint64_t guid` lands where the engine puts it.
// Return non-zero to keep iterating.
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx,
uint64_t guid);
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
// `ClntObjMgrObjectPtr(typeMask, dbg, guidLo, guidHi, dbgCode)` — resolves
// a GUID to its live `CGObject_C *`, filtered by typeMask; NULL if none.
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi,
int dbgCode);
struct ScanCtx {
uint32_t typeMask;
Guid::Type guidType;
uint32_t wantEntry;
float px, py, pz; // player position
Result r;
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
// Type + entry checks are pure GUID bit-reads, so we skip the (costly)
// object resolve for everything that can't match.
if (Guid::Classify(guid) != ctx->guidType)
return 1;
const uint32_t entry = static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
if (entry != ctx->wantEntry)
return 1;
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(ctx->typeMask, nullptr, static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
float pos[3];
if (!Unit::Position::Read(obj, pos))
return 1;
const float dx = pos[0] - ctx->px;
const float dy = pos[1] - ctx->py;
const float dz = pos[2] - ctx->pz;
const float distSq = dx * dx + dy * dy + dz * dz;
if (!ctx->r.found || distSq < ctx->r.distSq) {
ctx->r.found = true;
ctx->r.x = pos[0];
ctx->r.y = pos[1];
ctx->r.distSq = distSq;
}
return 1;
}
} // namespace
Result Find(uint32_t typeMask, Guid::Type guidType, uint32_t entry) {
const Result none{false, 0.0f, 0.0f, 0.0f};
if (entry == 0)
return none;
// The enumerator derefs the object manager unconditionally; it's NULL
// during glue / character-select. Bail rather than crash.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return none;
float ppos[3];
if (!Unit::Position::Read(Unit::Position::ResolveToken("player"), ppos))
return none;
ScanCtx ctx{typeMask, guidType, entry, ppos[0], ppos[1], ppos[2], none};
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&ScanCallback), &ctx);
return ctx.r;
}
} // namespace Object::ClosestByEntry
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 "guid/Guid.h"
#include <cstdint>
// Shared "nearest visible object of a given template entry" scan, backing
// both `ClosestUnitPosition` (creatures) and `ClosestGameObjectPosition`
// (game objects). Vanilla packs the template entry into GUID bits 24-47
// for the world-object types, so the only per-caller differences are the
// GUID type prefix and the object-manager type mask.
namespace Object::ClosestByEntry {
struct Result {
bool found;
float x, y; // world position of the nearest match
float distSq; // squared center-to-center distance from the player
};
// Nearest CURRENTLY-VISIBLE object whose GUID is of `guidType` (Creature /
// GameObject / …) and encodes template `entry` (GUID bits 24-47),
// relative to the local player. `typeMask` is the `ClntObjMgrObjectPtr`
// filter used to resolve the GUID (`TYPEMASK_UNIT` / `TYPEMASK_GAMEOBJECT`).
// Returns `{found=false}` for entry 0, pre-world, on a player-position
// miss, or when nothing matches.
Result Find(uint32_t typeMask, Guid::Type guidType, uint32_t entry);
} // namespace Object::ClosestByEntry
+4
View File
@@ -52,6 +52,7 @@
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "event/Custom.h"
#include "player/StatSignal.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -112,6 +113,9 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
// Gear swap changes item +healing and Spirit/Armor — invalidate the
// GetSpellBonusHealing cache.
Player::StatSignal::Notify();
}
return 1;
}
+28
View File
@@ -0,0 +1,28 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "player/StatSignal.h"
namespace Player::StatSignal {
namespace {
// Starts at 1 so a consumer whose cached epoch is default-0 always computes on
// its first query.
uint32_t g_epoch = 1;
} // namespace
void Notify() { ++g_epoch; }
uint32_t Epoch() { return g_epoch; }
} // namespace Player::StatSignal
+38
View File
@@ -0,0 +1,38 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// A pull-based "the local player's stat inputs changed" signal. `Notify()` is
// called from the aura and equipment hooks whenever the player gains/loses an
// aura or swaps gear — the state that feeds derived stats like
// `GetSpellBonusHealing` (flat +healing, plus the Spirit/Armor talent
// conversions that scale with those descriptor fields).
//
// Unlike Tick::WorldTick (push / per-frame callback), this is a monotonic
// epoch that consumers POLL: a lazy cache recomputes only when `Epoch()`
// differs from the value it last computed at, so a per-frame query is O(1)
// between real changes and never does work on idle frames.
namespace Player::StatSignal {
// Bump the epoch — called from the aura / equipment hooks on a
// player-affecting change.
void Notify();
// Current epoch; increments on each `Notify()`. A consumer caches the value it
// last recomputed at and recomputes only when this differs.
uint32_t Epoch();
} // namespace Player::StatSignal
+85
View File
@@ -76,6 +76,87 @@ static int __fastcall Script_GetQuestIDForLogIndex(void *L) {
return 1;
}
// `C_QuestLog.GetLogIndexForQuestID(questID)` — the inverse of
// `GetQuestIDForLogIndex`: the 1-based quest-log index of `questID`, or nil
// if the quest isn't in the log. The index spans the full entry array
// (headers included), matching `GetQuestIDForLogIndex` /
// `GetQuestLogTitle`. Walks `VAR_QUEST_LOG_ENTRIES`, skipping header rows via
// the `+8` header-pointer gate, and returns the first matching entry's index.
static int __fastcall Script_GetLogIndexForQuestID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetLogIndexForQuestID(questID)");
return 0;
}
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (target <= 0)
return 0; // nil — no such quest
const int total = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
auto *base = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
for (int i = 0; i < total; ++i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
continue; // header row
const int questID = *reinterpret_cast<const int *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID == target) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
return 1;
}
}
return 0; // nil — quest not in the log
}
// `C_QuestLog.GetHeaderIndexForQuest(questID)` — the 1-based log index of the
// collapsible category header (zone / "Dungeon" / class sort, …) the quest
// sits under, or nil if the quest isn't in the log or has no header above it.
// The log is laid out header-then-its-quests, so we locate the quest's entry
// and walk backwards to the nearest preceding header row (`+8` non-NULL).
static int __fastcall Script_GetHeaderIndexForQuest(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetHeaderIndexForQuest(questID)");
return 0;
}
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (target <= 0)
return 0; // nil — no such quest
const int total = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
auto *base = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
// Locate the quest's entry.
int questIndex = -1;
for (int i = 0; i < total; ++i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr)
continue; // header row
if (*reinterpret_cast<const int *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID) == target) {
questIndex = i;
break;
}
}
if (questIndex < 0)
return 0; // quest not in the log
// Walk back to the nearest preceding header.
for (int i = questIndex - 1; i >= 0; --i) {
auto *entry = base + i * Offsets::OFF_QUEST_LOG_ENTRY_STRIDE;
if (*reinterpret_cast<const void *const *>(
entry + Offsets::OFF_QUEST_LOG_ENTRY_HEADER_PTR) != nullptr) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based
return 1;
}
}
return 0; // nil — quest sits above any header (shouldn't normally happen)
}
// `C_QuestLog.IsOnQuest(questID)` — true iff `questID` is currently
// in the player's quest log (incomplete OR ready-to-turn-in; the log
// holds both). Walks `VAR_QUEST_LOG_ENTRIES` and matches against each
@@ -148,6 +229,10 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestIDForLogIndex",
&Script_GetQuestIDForLogIndex);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetLogIndexForQuestID",
&Script_GetLogIndexForQuestID);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetHeaderIndexForQuest",
&Script_GetHeaderIndexForQuest);
Game::Lua::RegisterTableFunction("C_QuestLog", "IsOnQuest",
&Script_IsOnQuest);
Game::Lua::RegisterTableFunction("C_QuestLog", "IsUnitOnQuest",
+295
View File
@@ -0,0 +1,295 @@
// 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 "spell/HealingAura.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. Stock vanilla auras are
// handled inline:
// 135 MOD_HEALING_DONE → flat += amount
// 175 MOD_SPELL_HEALING_OF_STAT_PERCENT → += Spirit × amount / 100 (Spiritual Guidance)
// Any other aura index is deferred to `Spell::HealingAura::Resolve`, the
// extension point where server-custom healing auras live (e.g. Turtle's
// Ironclad, aura 199 = × Armor — see src/turtle/HealingAura.cpp). Amounts/
// percents are read straight from Spell.dbc — generic 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
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
total += Spell::HealingAura::Resolve(aura[i], amount, spirit, armor);
}
}
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
+37
View File
@@ -0,0 +1,37 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 "spell/HealingAura.h"
namespace Spell::HealingAura {
namespace {
// Chained at static-init by each `AutoResolver` constructor, before
// `DllMain`. Zero-initialized ahead of any dynamic init.
AutoResolver *g_resolvers = nullptr;
} // namespace
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
g_resolvers = this;
}
long Resolve(int auraIndex, long amount, int32_t spirit, int32_t armor) {
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
if (const long v = r->fn(auraIndex, amount, spirit, armor))
return v;
return 0;
}
} // namespace Spell::HealingAura
+45
View File
@@ -0,0 +1,45 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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>
// Extension point for non-stock healing-aura semantics feeding
// `GetSpellBonusHealing`. The stock vanilla healing auras — 135
// (MOD_HEALING_DONE, flat) and 175 (MOD_SPELL_HEALING_OF_STAT_PERCENT,
// ×Spirit) — are decoded inline in `spell/BonusDamage.cpp`. Server-custom
// aura effects (e.g. Turtle's Ironclad, aura 199 = ×Armor) register a
// resolver here so their meaning lives outside the stock decoder.
namespace Spell::HealingAura {
// Flat healing contribution for a single Spell.dbc aura effect: the aura
// index plus its computed amount (base + dice), and the player's Spirit /
// Armor for stat-scaled auras. Returns 0 for aura indices no resolver
// recognizes. Consulted by the stock decoder for any effect that isn't a
// stock healing aura.
long Resolve(int auraIndex, long amount, int32_t spirit, int32_t armor);
// A resolver maps one aura effect to its flat healing contribution, or
// returns 0 if it doesn't recognize the aura index. Declare a file-scope
// `static const Spell::HealingAura::AutoResolver` in a platform module to
// register one at static-init time.
using Resolver = long (*)(int auraIndex, long amount, int32_t spirit,
int32_t armor);
struct AutoResolver {
explicit AutoResolver(Resolver fn);
Resolver fn;
AutoResolver *next;
};
} // namespace Spell::HealingAura
+30
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "player/StatSignal.h"
#include "spell/Arg.h"
#include "spell/Lookup.h"
@@ -586,6 +587,35 @@ static int __fastcall Script_C_Spell_IsSpellHelpful(void *L) {
return 1;
}
// --- Known-spell-bitmap change signal ------------------------------------
// Learning/unlearning a spell rewrites the bitmap GetSpellBonusHealing scans
// for talent conversions (Spiritual Guidance / Ironclad). Co-hook the engine's
// learn + unlearn writers and bump Player::StatSignal, so a talent respec
// invalidates the healing cache immediately — the same event-driven path as
// aura/equipment changes, no polling. Both fire only at login and on
// learn/unlearn (never per-frame), so they're safe co-hook targets.
using LearnSpell_t = void(__fastcall *)(uint32_t spellID, int notify,
uint32_t replacedSpellID);
static LearnSpell_t g_origLearnSpell = nullptr;
static void __fastcall LearnSpell_h(uint32_t spellID, int notify,
uint32_t replacedSpellID) {
g_origLearnSpell(spellID, notify, replacedSpellID);
Player::StatSignal::Notify();
}
static const Game::HookAutoRegister _hookLearnSpell{
Offsets::FUN_LEARN_SPELL, reinterpret_cast<void *>(&LearnSpell_h),
reinterpret_cast<void **>(&g_origLearnSpell)};
using UnlearnSpell_t = void(__fastcall *)(uint32_t spellID, int param2);
static UnlearnSpell_t g_origUnlearnSpell = nullptr;
static void __fastcall UnlearnSpell_h(uint32_t spellID, int param2) {
g_origUnlearnSpell(spellID, param2);
Player::StatSignal::Notify();
}
static const Game::HookAutoRegister _hookUnlearnSpell{
Offsets::FUN_UNLEARN_SPELL, reinterpret_cast<void *>(&UnlearnSpell_h),
reinterpret_cast<void **>(&g_origUnlearnSpell)};
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSpellInfo", &Script_GetSpellInfo);
Game::Lua::RegisterGlobalFunction("GetSpellLink", &Script_GetSpellLink);
+546
View File
@@ -0,0 +1,546 @@
// 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/>.
// `GetTotemInfo(slot)` — the four-element totem bar, backported. Vanilla
// 1.12 tracks nothing about the shaman's totems client-side (the totem
// bar, PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
// this is a self-tracking subsystem — but a fully DATA-DRIVEN one:
//
// * SLOT is read straight from the totem summon spell's `Spell.dbc`
// effect: 87/88/89/90 = SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air,
// so `slot = effect - 86`. No hardcoded spell table; Turtle custom
// totems self-classify.
// * CREATURE ENTRY is the effect's `EffectMiscValue` (the summoned
// totem's creature-template ID) — used to detect the totem's death.
// * DURATION is the summon spell's `SpellDuration.dbc` value (via the
// engine's own duration helper, so player duration mods apply).
//
// Detection: the player's totem cast fires SMSG_SPELL_GO, which
// `Aura::Source` already co-hooks; it calls `OnPlayerSpellGo` here (before
// its aura gate, since a summon applies no aura). We stamp the slot with
// start time + duration.
//
// Death / removal: on WorldTick we scan the object manager for a
// player-owned creature of the slot's entry. A "seen once, then gone"
// latch clears the slot early (totem killed, Totemic Recall, or manually
// destroyed) — the owner check (CreatedBy == player) means another
// shaman's same-entry totem can't keep ours alive, and the seen-latch
// tolerates the ~1-tick spawn lag before the totem appears. Duration is
// the backstop for the normal case. A recast into the same slot simply
// re-stamps it.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
#include "guid/Guid.h"
#include "item/Count.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Totem::Tracker {
namespace {
// Spell.dbc record field offsets (see spell/Info.cpp for the shared set).
constexpr int OFF_EFFECT = 0xF4; // int32[3] — SPELL_EFFECT_*
constexpr int OFF_TOTEM_TOOL = 0xA0; // int32[2] — Totem (required tool item IDs)
constexpr int OFF_MISC_VALUE = 0x1A8; // int32[3] — EffectMiscValue
constexpr int OFF_ICON_ID = 0x1D4; // int32 — SpellIconID
constexpr int OFF_NAME = 0x1E0; // char*[9] locale array
// SUMMON_TOTEM_SLOT1..4 spell effects (verified against Spell.dbc: Searing
// 87/Fire, Stoneskin 88/Earth, Healing Stream 89/Water, Windfury 90/Air).
constexpr int kEffectSummonTotemSlot1 = 87;
constexpr int kEffectSummonTotemSlot4 = 90;
constexpr int kSlots = 4;
// Re-scan the object manager for totem existence at most this often.
constexpr uint32_t kScanIntervalMs = 250;
struct Slot {
bool active;
bool seen; // the totem creature has been observed in the world
uint32_t spellID;
uint32_t entry; // summoned creature-template ID (for death detection)
uint32_t startMs; // FUN_OS_TICKCOUNT_MS (shares GetTime's epoch × 1000)
uint32_t durationMs;
};
Slot g_slots[kSlots] = {};
uint32_t g_lastScanMs = 0;
// Per-slot totem tool item ID (Fire/Earth/Water/Air), read from the totem
// spell's `Totem[0]` field — the required TOOL the spell lists (Earth
// Totem, Fire Totem, …), NOT a consumed reagent, and NOT hardcoded. Filled
// from the player's spellbook on SPELLS_CHANGED and refreshed at cast; 0 =
// not yet resolved. `g_spellsChangedId` caches the event id.
uint32_t g_toolItem[kSlots] = {};
int g_spellsChangedId = -1;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// The totem tool item a spell requires (`Totem[0]`), or 0.
uint32_t TotemToolItem(const uint8_t *rec) {
const int32_t t = *reinterpret_cast<const int32_t *>(rec + OFF_TOTEM_TOOL);
return t > 0 ? static_cast<uint32_t>(t) : 0;
}
// Fill any still-unknown slot tool items by scanning the player's spellbook
// for totem summons and reading each one's required tool. Tools are fixed
// per element, so once a slot is resolved it never changes and the scan
// early-outs. Walks the flat learned-spell array `VAR_PLAYER_SPELLBOOK`
// (the same list `GetSpellName` reads) — the `IsPlayerSpell` bitmap
// doesn't reliably carry totems. Driven by SPELLS_CHANGED (when the
// spellbook is populated), so no polling.
void ScanTools() {
bool anyUnknown = false;
for (int s = 0; s < kSlots; ++s)
if (g_toolItem[s] == 0) {
anyUnknown = true;
break;
}
if (!anyUnknown)
return;
auto *book = reinterpret_cast<const int32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
for (int slot = 0; slot < Offsets::SPELLBOOK_MAX_SLOTS; ++slot) {
const int spellID = book[slot];
if (spellID <= 0)
continue;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
continue;
const auto *eff = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e < kEffectSummonTotemSlot1 || e > kEffectSummonTotemSlot4)
continue;
const int s = e - kEffectSummonTotemSlot1;
if (g_toolItem[s] == 0)
g_toolItem[s] = TotemToolItem(rec);
break;
}
}
}
// Re-resolve tool items whenever the player's spells (re)load.
// SPELLS_CHANGED is a no-arg event through the same dispatcher
// Event::SignalHook owns, so we observe it there (never suppress — return
// false). By the time it fires the spellbook is populated, so the scan
// sees the totems.
bool OnSignal(int eventID) {
if (g_spellsChangedId < 0)
g_spellsChangedId = Event::Custom::LookupByName("SPELLS_CHANGED");
if (eventID == g_spellsChangedId)
ScanTools();
return false;
}
const Event::SignalHook::AutoSubscribe _spellsChanged{&OnSignal};
uint32_t SpellDurationMs(const uint8_t *rec) {
using GetDuration_t = int(__fastcall *)(const uint8_t *rec, int unit,
char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(rec, 0, 0);
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- object-manager existence scan (player-owned creature by entry) ------
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx,
uint64_t guid);
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi,
int dbgCode);
struct ScanCtx {
uint64_t playerGuid;
uint32_t entries[kSlots]; // 0 = slot not being checked
bool present[kSlots];
};
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
if (!Guid::IsCreature(guid))
return 1;
const uint32_t entry = static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
if (entry == 0)
return 1;
int match = -1;
for (int s = 0; s < kSlots; ++s) {
if (ctx->entries[s] == entry) {
match = s;
break;
}
}
if (match < 0)
return 1;
// Confirm it's OUR totem: resolve + check CreatedBy == player.
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 1;
const uint64_t createdBy = *reinterpret_cast<const uint64_t *>(
desc + Offsets::OFF_UNIT_FIELD_CREATEDBY);
if (createdBy == ctx->playerGuid)
ctx->present[match] = true;
return 1; // keep scanning — other slots may match other creatures
}
// On-demand lookup of the live GUID of the player's totem creature of a
// given entry (for TargetTotem). Returns 0 when none is visible.
struct FindGuidCtx {
uint64_t playerGuid;
uint32_t entry;
uint64_t found;
};
int __fastcall FindGuidCallback(FindGuidCtx *ctx, void * /*unusedEdx*/,
uint64_t guid) {
if (!Guid::IsCreature(guid))
return 1;
if (static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu) != ctx->entry)
return 1;
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (obj == nullptr)
return 1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 1;
if (*reinterpret_cast<const uint64_t *>(
desc + Offsets::OFF_UNIT_FIELD_CREATEDBY) == ctx->playerGuid) {
ctx->found = guid;
return 0; // stop
}
return 1;
}
uint64_t FindTotemGuid(uint32_t entry) {
if (entry == 0)
return 0;
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return 0;
const uint64_t player = Unit::Identity::PlayerGuid();
if (player == 0)
return 0;
FindGuidCtx ctx{player, entry, 0};
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&FindGuidCallback), &ctx);
return ctx.found;
}
// PLAYER_TOTEM_UPDATE(totemSlot 1-4) — a TBC event absent from vanilla's
// table, reserved as a custom event and fired when a slot's state changes
// (totem dropped, expired, killed, or recalled). Changes are recorded in a
// dirty mask by the cast path and the clear paths, then flushed from OnTick
// (WorldTick context — safe to run Lua handlers, unlike mid-packet).
constexpr const char *kPlayerTotemUpdate = "PLAYER_TOTEM_UPDATE";
const Event::Custom::AutoReserve _totemUpdate{kPlayerTotemUpdate};
int g_totemUpdateId = -1;
uint8_t g_dirtyMask = 0;
void MarkDirty(int slotIdx) {
g_dirtyMask |= static_cast<uint8_t>(1u << slotIdx);
}
void FireDirty() {
if (g_dirtyMask == 0)
return;
if (g_totemUpdateId < 0)
g_totemUpdateId = Event::Custom::LookupByName(kPlayerTotemUpdate);
if (g_totemUpdateId < 0)
return; // slot not claimed yet — keep dirty, retry next tick
for (int s = 0; s < kSlots; ++s)
if (g_dirtyMask & (1u << s))
Event::Custom::Fire(g_totemUpdateId, "%d", s + 1);
g_dirtyMask = 0;
}
void OnTick() {
const uint32_t now = NowMs();
// Duration backstop — always cheap, runs every tick.
bool anyActive = false;
for (int s = 0; s < kSlots; ++s) {
Slot &t = g_slots[s];
if (!t.active)
continue;
if (t.durationMs != 0 && now - t.startMs >= t.durationMs) {
t.active = false;
MarkDirty(s);
continue;
}
anyActive = true;
}
// Death / removal scan — throttled, and only while a slot is up. The
// object-manager enumerator derefs the manager unconditionally, so it's
// gated on the local player existing.
if (anyActive && now - g_lastScanMs >= kScanIntervalMs &&
*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) !=
nullptr) {
const uint64_t player = Unit::Identity::PlayerGuid();
if (player != 0) {
g_lastScanMs = now;
ScanCtx ctx{};
ctx.playerGuid = player;
for (int s = 0; s < kSlots; ++s)
ctx.entries[s] = g_slots[s].active ? g_slots[s].entry : 0;
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&ScanCallback), &ctx);
for (int s = 0; s < kSlots; ++s) {
Slot &t = g_slots[s];
if (!t.active)
continue;
if (ctx.present[s])
t.seen = true;
else if (t.seen) {
t.active = false; // was up, now gone → died / recalled
MarkDirty(s);
}
}
}
}
FireDirty();
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
// SpellIcon.dbc path for an icon ID (records indexed by ID; path at +4).
const char *IconPath(int iconID) {
if (iconID <= 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_ICON_COUNT));
if (iconID > count)
return nullptr;
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_ICON_RECORDS));
if (records == nullptr || records[iconID] == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(records[iconID] + 4);
}
// Pushes the empty tail (no active totem) after `haveTotem` has been
// pushed by the caller. Returns the total count including that first value.
int PushEmptyTail(void *L) {
Game::Lua::PushString(L, ""); // totemName
Game::Lua::PushNumber(L, 0.0); // startTime
Game::Lua::PushNumber(L, 0.0); // duration
Game::Lua::PushNil(L); // icon
Game::Lua::PushNumber(L, 1.0); // modRate
Game::Lua::PushNumber(L, 0.0); // spellID
return 7;
}
// `GetTotemInfo(slot)` → `haveTotem, totemName, startTime, duration, icon,
// modRate, spellID`. Slots: 1 Fire, 2 Earth, 3 Water, 4 Air. `haveTotem`
// is TOOL presence (the documented meaning) — whether the player carries
// the slot's totem tool item (Earth/Fire/Water/Air Totem) — NOT whether a
// totem is summoned; the active totem is signaled by a non-empty
// `totemName` / non-zero `startTime`.
int __fastcall Script_GetTotemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemInfo(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (slot < 1 || slot > kSlots) {
Game::Lua::PushBool(L, false);
return PushEmptyTail(L);
}
// Tool presence — independent of the summoned state. The tool item
// (resolved from the totem spell's Totem[0] on SPELLS_CHANGED, not
// hardcoded) is checked against the bag. The bag walk reads the Lua
// stack, so it runs before we push any return values (`slot` captured).
const uint32_t tool = g_toolItem[slot - 1];
const bool haveTotem =
tool != 0 && Item::Count::InInventory(L, static_cast<int>(tool)) > 0;
const Slot &t = g_slots[slot - 1];
if (!t.active) {
Game::Lua::PushBool(L, haveTotem);
return PushEmptyTail(L);
}
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(t.spellID));
const char *name = "";
const char *icon = nullptr;
if (rec != nullptr) {
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(
Offsets::VAR_LOCALE_INDEX));
const char *n = *reinterpret_cast<const char *const *>(
rec + OFF_NAME + locale * 4);
if (n != nullptr)
name = n;
icon = IconPath(*reinterpret_cast<const int *>(rec + OFF_ICON_ID));
}
Game::Lua::PushBool(L, haveTotem);
Game::Lua::PushString(L, name);
Game::Lua::PushNumber(L, static_cast<double>(t.startMs) * 0.001);
Game::Lua::PushNumber(L, static_cast<double>(t.durationMs) / 1000.0);
if (icon != nullptr)
Game::Lua::PushString(L, icon);
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, 1.0); // modRate — no per-totem haste in 1.12
Game::Lua::PushNumber(L, static_cast<double>(t.spellID));
return 7;
}
// `GetTotemTimeLeft(slot)` → seconds until the slot's totem is auto-
// destroyed, or `0` when no totem is active in that slot. Slots: 1 Fire,
// 2 Earth, 3 Water, 4 Air.
int __fastcall Script_GetTotemTimeLeft(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemTimeLeft(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
double seconds = 0.0;
if (slot >= 1 && slot <= kSlots) {
const Slot &t = g_slots[slot - 1];
if (t.active && t.durationMs != 0) {
const uint32_t now = NowMs();
const uint32_t end = t.startMs + t.durationMs;
if (now < end)
seconds = static_cast<double>(end - now) / 1000.0;
}
}
Game::Lua::PushNumber(L, seconds);
return 1;
}
// `GetTotemDuration(slot)` → the total duration (seconds) of the slot's
// active totem, or `0` when none. This is the totem's full lifetime from
// cast (the 4th value of GetTotemInfo); see GetTotemTimeLeft for the
// remaining time.
int __fastcall Script_GetTotemDuration(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTotemDuration(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
double seconds = 0.0;
if (slot >= 1 && slot <= kSlots) {
const Slot &t = g_slots[slot - 1];
if (t.active)
seconds = static_cast<double>(t.durationMs) / 1000.0;
}
Game::Lua::PushNumber(L, seconds);
return 1;
}
// `TargetTotem(slot)` — target the player's totem in the given slot
// (1 Fire, 2 Earth, 3 Water, 4 Air). No-op when the slot has no active
// totem or the totem creature isn't currently visible. Finds the totem
// creature's live GUID and sets the target via the same engine path
// `TargetUnit` uses.
int __fastcall Script_TargetTotem(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: TargetTotem(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (slot < 1 || slot > kSlots)
return 0;
const Slot &t = g_slots[slot - 1];
if (!t.active || t.entry == 0)
return 0;
uint64_t guid = FindTotemGuid(t.entry);
if (guid == 0)
return 0;
using TargetByGuid_t = void(__fastcall *)(const uint64_t *guid);
reinterpret_cast<TargetByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_TARGET_BY_GUID))(&guid);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemInfo", &Script_GetTotemInfo);
Game::Lua::RegisterGlobalFunction("GetTotemTimeLeft",
&Script_GetTotemTimeLeft);
Game::Lua::RegisterGlobalFunction("GetTotemDuration",
&Script_GetTotemDuration);
Game::Lua::RegisterGlobalFunction("TargetTotem", &Script_TargetTotem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
void OnPlayerSpellGo(uint32_t spellID) {
if (spellID == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
return;
const auto *effects = reinterpret_cast<const int32_t *>(rec + OFF_EFFECT);
const auto *misc = reinterpret_cast<const int32_t *>(rec + OFF_MISC_VALUE);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int eff = effects[i];
if (eff < kEffectSummonTotemSlot1 || eff > kEffectSummonTotemSlot4)
continue;
const int idx = eff - kEffectSummonTotemSlot1; // 0..3 (Fire..Air)
Slot &t = g_slots[idx];
t.active = true;
t.seen = false;
t.spellID = spellID;
t.entry = static_cast<uint32_t>(misc[i]);
t.startMs = NowMs();
t.durationMs = SpellDurationMs(rec);
MarkDirty(idx); // totem dropped → PLAYER_TOTEM_UPDATE (fired next tick)
// The cast spell IS a totem of this element, so it names the slot's
// tool item directly — capture it (covers the slot even before the
// spellbook scan runs).
if (g_toolItem[idx] == 0)
g_toolItem[idx] = TotemToolItem(rec);
return; // one totem-slot effect per spell
}
}
} // namespace Totem::Tracker
+31
View File
@@ -0,0 +1,31 @@
// 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>
// Client-side totem-slot tracker backing `GetTotemInfo(slot)`. Vanilla
// 1.12 has no totem-bar / PLAYER_TOTEM_UPDATE machinery (all TBC
// additions), so we track the four element slots ourselves. See
// Tracker.cpp for the design.
namespace Totem::Tracker {
// Notify of a spell the LOCAL PLAYER just cast (from Aura::Source's
// SpellGo hook — the totem summon doesn't apply an aura, so it must be
// fed before that hook's aura gate). If `spellID` is a totem summon
// (`Spell.dbc` effect 87-90 = SUMMON_TOTEM_SLOT1..4), it's recorded in the
// matching element slot; otherwise ignored.
void OnPlayerSpellGo(uint32_t spellID);
} // namespace Totem::Tracker
+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/>.
// Turtle WoW custom bag families. Turtle added four cooking/gathering bag
// families as `ItemBagFamily.dbc` rows 10–13 (Meat, Fish, Leather,
// Mining) — beyond retail vanilla, which stops at Keys (9). Turtle's own
// bags normally carry `m_bagFamily` directly, so this mapping is a safety
// net for any custom container shipped with an empty field; it plugs into
// the stock `Item::BagFamily` resolver chain rather than polluting the
// stock table with non-vanilla data.
//
// Subclass → raw family ID cross-referenced by name between
// ItemSubClass.dbc (class 1, Container) and ItemBagFamily.dbc. NOTE: the
// resulting bitmasks (`1 << (id-1)`) land on `0x200`–`0x1000`, which
// collide with retail's Gem/Mining bag bits — see docs/API.md's
// GetItemFamily family table for the caveat. The values stay
// self-consistent within Turtle (a Meat Bag and the meat it holds both
// report `0x200`).
//
// This file is the home for Turtle-only custom content that layers onto a
// stock engine surface; add further custom-data modules alongside it.
#include "item/BagFamily.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::BagFamily {
namespace {
// Turtle custom Container-class subclasses → `ItemBagFamily.dbc` raw IDs.
// Stock subclasses (Soul/Herb/Enchanting/Engineering) are handled by
// `Item::BagFamily`'s own table and never reach here. Gated on Turtle
// detection so the custom mapping can't misfire on a stock client (where
// these subclasses don't exist as containers anyway).
uint32_t Resolve(uint32_t itemClass, uint32_t subclass) {
if (!Turtle::Detected() || itemClass != Item::BagFamily::kItemClassContainer)
return 0;
switch (subclass) {
case 6: return 13; // Mining Bag → Mining Supplies
case 7: return 12; // Leatherworking Bag → Leather
case 8: return 10; // Meat Bag → Meat
case 9: return 11; // Fish Bag → Fish
default: return 0;
}
}
const Item::BagFamily::AutoResolver _register{&Resolve};
} // namespace
} // namespace Turtle::BagFamily
+66
View File
@@ -0,0 +1,66 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
// Turtle WoW reworked Rip — known combo-finisher durations for a spell
// whose client DBC row is missing. All six Rip ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live data shows 10/12/14/16/18s at 1..5 CP, i.e.
// 8s + 2s per combo point); the client patch never shipped the row (the
// client table jumps 86 → 105), so the index dangles. Verified the only
// dangling duration index in the entire client Spell.dbc.
//
// This supplies the values through `Aura::ComboDuration`'s dangling-row
// resolver so the Turtle-specific data lives here, not in the stock
// module. Unlike the other `src/turtle/` modules it does NOT gate on
// `Turtle::Detected()`: the activation gate is the dangling-row condition
// in `Aura::ComboDuration` (a client-data bug unique to this Rip), which
// is the deliberate design — gate on the bug, not the realm. On a stock
// client Rip resolves to a valid row and this resolver is never consulted;
// if Turtle ever ships the missing client row, the DBC takes over and this
// goes dormant automatically.
#include "aura/ComboDuration.h"
#include <cstdint>
namespace Turtle::ComboDuration {
namespace {
struct Known {
uint32_t spellId;
int32_t baseMs;
int32_t maxMs;
};
constexpr Known kRip[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
bool Resolve(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (const auto &k : kRip) {
if (k.spellId == spellId) {
*baseMs = k.baseMs;
*maxMs = k.maxMs;
return true;
}
}
return false;
}
const Aura::ComboDuration::AutoDanglingResolver _register{&Resolve};
} // namespace
} // namespace Turtle::ComboDuration
+69
View File
@@ -0,0 +1,69 @@
// 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 "turtle/Detect.h"
#include "Game.h"
#include <cstring>
namespace Turtle {
namespace {
// Latched once we've read the global as a string — Turtle never un-sets
// it, so a true result is permanent. A false read is NOT cached: FrameXML
// may not have run yet, so we re-probe on each call until it lands.
bool g_detected = false;
char g_version[32] = {0};
void Probe() {
if (g_detected)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Read `_G["TURTLE_WOW_VERSION"]` without disturbing the caller's
// stack. RawGet avoids any (nonexistent, but free-to-skip) metatable
// side effects on the globals table.
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "TURTLE_WOW_VERSION");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::IsString(L, -1)) {
const char *v = Game::Lua::ToString(L, -1);
if (v != nullptr) {
const size_t n = std::strlen(v);
const size_t copy =
(n < sizeof(g_version) - 1) ? n : sizeof(g_version) - 1;
std::memcpy(g_version, v, copy);
g_version[copy] = '\0';
}
g_detected = true;
}
Game::Lua::SetTop(L, top);
}
} // namespace
bool Detected() {
Probe();
return g_detected;
}
const char *Version() {
Probe();
return g_detected ? g_version : nullptr;
}
} // namespace Turtle
+37
View File
@@ -0,0 +1,37 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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
// Turtle WoW client detection — the gate for every module under
// `src/turtle/`. Turtle's FrameXML sets a `TURTLE_WOW_VERSION` global (see
// `FrameXML/Globals.lua`, e.g. `"1.18.1"`); its presence is the
// authoritative "this is the Turtle client" signal — no realm-list
// sniffing (which conflates "connected to Turtle" with "running the
// Turtle client" and was rejected for the Rip combo-duration work), no
// binary fingerprinting.
namespace Turtle {
// True iff running on a Turtle WoW client. Reads the `TURTLE_WOW_VERSION`
// global and LATCHES true once seen. Safe to call any time: it returns
// false until the in-game Lua state exists AND FrameXML's Globals.lua has
// run (so it can read false very early in boot / on the glue screen), then
// flips true and stays true. Never caches a false result, so an early
// probe can't wrongly pin a Turtle client as non-Turtle.
bool Detected();
// The Turtle client version string (e.g. `"1.18.1"`), or nullptr when not
// Turtle / not yet detected. Points at an internally-cached copy.
const char *Version();
} // namespace Turtle
+44
View File
@@ -0,0 +1,44 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
// Turtle WoW custom healing auras. Aura 199
// (MOD_SPELL_HEALING_OF_ARMOR_PERCENT, "Ironclad") adds
// `Armor × amount / 100` to the player's spell healing bonus — not a stock
// vanilla aura index. Plugs into `Spell::HealingAura`'s resolver chain so
// the Turtle-specific aura semantic lives here, not in the stock
// GetSpellBonusHealing decoder. Gated on Turtle detection (the aura index
// is a generic slot number that could mean something else on another
// server).
#include "spell/HealingAura.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::HealingAura {
namespace {
constexpr int kAuraModHealingOfArmorPercent = 199; // × Armor (Ironclad)
long Resolve(int auraIndex, long amount, int32_t /*spirit*/, int32_t armor) {
if (!Turtle::Detected() || auraIndex != kAuraModHealingOfArmorPercent)
return 0;
return static_cast<long>(armor) * amount / 100;
}
const Spell::HealingAura::AutoResolver _register{&Resolve};
} // namespace
} // namespace Turtle::HealingAura
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestUnitPosition(creatureID)` → `xPos, yPos, distance` — the world
// position of the nearest creature with the given NPC (creature-template)
// ID, and its distance from the player in yards. Returns nothing when no
// matching creature is found (the modern `MayReturnNothing` contract).
//
// Semantic note vs. retail: modern WoW's `ClosestUnitPosition` reads a
// static client-side spawn database and only works for starting-zone mobs
// (a tutorial/help helper). Vanilla 1.12 has no such database, so we
// implement the useful, 1.12-native reading: the closest creature of that
// entry that is CURRENTLY VISIBLE (in the client's object manager /
// broadcast window). The shared scan lives in Object::ClosestByEntry.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace Unit::ClosestPosition {
namespace {
int __fastcall Script_ClosestUnitPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestUnitPosition(creatureID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_UNIT, Guid::Type::Creature, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestUnitPosition",
&Script_ClosestUnitPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::ClosestPosition
+22
View File
@@ -163,6 +163,27 @@ int __fastcall Script_UnitIsOtherPlayersPet(void *L) {
return 1;
}
// `UnitOwnerGUID(unit)` — the GUID string of the unit's owner (the
// summoner of a pet/guardian/totem, or the charmer of a charmed unit),
// formatted `"0x…"`. Reuses the same owner field the pet predicates use
// (CharmedBy, else CreatedBy). Returns `nil` for an unresolved unit and
// for anything with no owner (players, world creatures).
int __fastcall Script_UnitOwnerGUID(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint64_t owner = OwnerGuid(unit);
if (owner == 0) {
Game::Lua::PushNil(L);
return 1;
}
char buf[Guid::STRING_SIZE];
Game::Lua::PushString(L, Guid::FormatAsString(owner, buf, sizeof buf));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
@@ -170,6 +191,7 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitIsPet", &Script_UnitIsPet);
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
&Script_UnitIsOtherPlayersPet);
Game::Lua::RegisterGlobalFunction("UnitOwnerGUID", &Script_UnitOwnerGUID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+69 -16
View File
@@ -13,20 +13,33 @@
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
//
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check (fixed threshold on the squared distance).
// range check. Reach-aware: matches the engine's own spell-range
// formula (see below), so a 40-yard heal registers in-range against
// a target whose *center* is slightly past 40 yards but whose body
// is within reach.
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
// the raw squared world distance player→unit. Squared because
// nearly every consumer compares against a threshold (`distSq <=
// range*range`) or ranks by nearest, neither of which needs the
// sqrt; that's why retail exposes only the squared form (added
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
// but self-contained (reads the position in C, no sibling-DLL
// dependency, no clash with SuperWoW's own `UnitPosition`).
// 5.0.4). Center-to-center — this one is NOT reach-adjusted (retail's
// `UnitDistanceSquared` is also raw center distance); the reach
// correction lives only in `UnitInRange`.
//
// Reach-awareness: the engine's spell-range helper `FUN_006e3480` compares
// center distance against `baseRange + reachCaster + reachTarget`, where
// each unit's reach is the bounding-radius float at `[m_objectFields +
// 0x1F0]` (the same size factor the interact-distance and loot-range
// checks add). So the effective in-range distance for a 40-yard heal is
// `40 + playerReach + targetReach`. On big targets that's easily 2–3
// yards past the raw 40, which is why a pure center-to-center 40-yard cap
// reported out-of-range for a target the client could actually heal.
#include "Game.h"
#include "unit/Position.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Unit::Range {
@@ -34,18 +47,44 @@ namespace Unit::Range {
namespace {
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
// Squared world distance from the player to `token`'s unit. Returns true
// and writes *outSq on success; false when either position is unavailable
// (unresolved / absent token, or an object with no known position yet —
// e.g. a party member outside the client's sync range). This false is
// exactly the `checkedPosition = false` case retail flags.
// Reads a unit's bounding radius (the engine's per-unit range "size
// factor") from `[m_objectFields + 0x1F0]`. Returns 0 for a null object
// or a unit whose descriptor isn't populated yet — a safe fallback that
// degrades to the plain base-range check. Same field the loot/interact
// range checks (loot/Nearby.cpp) read.
float BoundingRadius(void *obj) {
if (obj == nullptr)
return 0.0f;
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (fields == nullptr)
return 0.0f;
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Resolves `token` once and reads both its world position and bounding
// radius. Returns false when the position is unavailable (unresolved /
// absent token, or an object with no known position yet — e.g. a party
// member outside the client's sync range). This false is exactly the
// `checkedPosition = false` case retail flags.
bool ReadPosAndReach(const char *token, float pos[3], float *reach) {
void *obj = Unit::Position::ResolveToken(token);
if (!Unit::Position::Read(obj, pos))
return false;
*reach = BoundingRadius(obj);
return true;
}
// Squared world distance from the player to `token`'s unit (raw,
// center-to-center; reach is applied by the caller when needed).
bool PlayerToUnitDistSq(const char *token, float *outSq) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos))
float reach = 0.0f;
if (!ReadPosAndReach(token, unitPos, &reach) ||
!ReadPosAndReach("player", playerPos, &reach))
return false;
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
@@ -72,14 +111,28 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
float unitPos[3] = {};
float playerPos[3] = {};
float unitReach = 0.0f;
float playerReach = 0.0f;
if (!ReadPosAndReach(token, unitPos, &unitReach) ||
!ReadPosAndReach("player", playerPos, &playerReach)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
const float distSq = dx * dx + dy * dy + dz * dz;
// Effective range = base + both units' reach, mirroring the engine's
// spell-range formula (FUN_006e3480). Without the reach terms a target
// whose center sits just past 40 yards but whose body is within reach
// reports out-of-range even though a 40-yard heal would land.
const float effRange = kRangeYards + playerReach + unitReach;
Game::Lua::PushBool(L, distSq <= effRange * effRange);
Game::Lua::PushBool(L, true);
return 2;
}