Compare commits

...
7 Commits
Author SHA1 Message Date
Brues 93b4dc5db8 PlayerBuffExpirationSeconds: read tick value as uint32, not signed int
The engine stores expiration timestamps as GetTickCount-derived uint32
milliseconds. We were reading as signed int, then bailing on `<= 0` to
catch the "no timing" sentinel of 0. Once system uptime crossed 2^31 ms
(~24.86 days) the high bit flipped, signed reads went negative, and
every active buff started reporting expirationTime = 0 while
GetTime() kept working through a different code path.

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

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

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

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

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

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

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

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

Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
2026-06-04 14:39:01 -05:00
17 changed files with 1598 additions and 61 deletions
-10
View File
@@ -1,10 +0,0 @@
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
FOCUSTARGET = "Focus Target";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
ITEM_MISSING = "%s missing";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
TARGETFOCUS = "Target Focus";
+2 -1
View File
@@ -25,8 +25,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
+405 -10
View File
@@ -46,6 +46,14 @@ build instructions.
- [`C_Container.SwapItems(srcBag, srcSlot, dstBag, dstSlot)`](#c_containerswapitemssrcbag-srcslot-dstbag-dstslot)
- [`C_Container.MoveItem(srcBag, srcSlot, dstBag, dstSlot, count)`](#c_containermoveitemsrcbag-srcslot-dstbag-dstslot-count)
- [Coroutine](#coroutine)
- [`coroutine.create(fn)`](#coroutinecreatefn)
- [`coroutine.resume(co, ...)`](#coroutineresumeco-)
- [`coroutine.yield(...)`](#coroutineyield)
- [`coroutine.status(co)`](#coroutinestatusco)
- [`coroutine.wrap(fn)`](#coroutinewrapfn)
- [Async pattern (RunAsync + C_Timer.After)](#async-pattern-runasync--c_timerafter)
- [CVar](#cvar)
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
@@ -85,6 +93,7 @@ build instructions.
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [Expansion](#expansion)
@@ -162,6 +171,7 @@ build instructions.
- [`C_Item.EquipItemByName(itemInfo [, dstSlot])`](#c_itemequipitembynameiteminfo--dstslot)
- [`C_Item.GetCurrentItemLevel(itemLocation)` / `C_Item.GetDetailedItemLevelInfo(item)`](#c_itemgetcurrentitemlevelitemlocation--c_itemgetdetaileditemlevelinfoitem)
- [`C_Item.GetItemCount(itemInfo, [includeBank], [includeUses])`](#c_itemgetitemcountiteminfo-includebank-includeuses)
- [`C_Item.GetItemData(itemLocation)` / `C_Item.GetItemDataByID(item)`](#c_itemgetitemdataitemlocation--c_itemgetitemdatabyiditem)
- [`C_Item.GetItemFamily(item)`](#c_itemgetitemfamilyitem)
- [`C_Item.GetItemGUID(itemLocation)`](#c_itemgetitemguiditemlocation)
- [`C_Item.GetItemID(itemLocation)`](#c_itemgetitemiditemlocation)
@@ -1212,6 +1222,146 @@ constraint as `SwapItems`.
Send is fire-and-forget (same as `SwapItems`).
## Coroutine
Restores Lua 5.0's stripped `coroutine.*` library. The C-level
coroutine machinery (`lua_resume`, `lua_yield`, `lua_newthread`,
and the `thread` type at tag 8) is linked into the engine, but
the script-facing library was never registered as a global table.
ClassicAPI rewires the standard five entries on `coroutine`,
matching Lua 5.0 semantics with two WoW-specific quirks called
out below.
### `coroutine.create(fn)`
Creates a new coroutine with `fn` as its body and returns the
resulting `thread`. `fn` must be a **Lua** function — passing a
C function (any engine global like `GetTime` or `UnitName`)
raises `bad argument #1 to 'create' (Lua function expected)`.
The new coroutine starts suspended; nothing runs until
`coroutine.resume` is called on it. The args passed to the first
resume become the body's arguments.
### `coroutine.resume(co, ...)`
Resumes a suspended coroutine, passing the trailing args to either
the body (first resume) or back as the return values of the
`coroutine.yield` that paused the coroutine.
Returns:
- `true, ...values` on success, where `values` are whatever the
coroutine yielded (mid-execution) or returned (on completion).
- `false, errMsg` on error. Two error sources:
- Coroutine isn't suspended — e.g. resuming a dead one;
`errMsg` is a fixed string like `"cannot resume dead coroutine"`.
- The body hit a Lua VM error; `errMsg` is the formatted error
string with file:line prefix.
**Caveat — `error()` doesn't propagate.** WoW replaces Lua's
`error()` global with a soft handler that calls
`geterrorhandler()(msg)` and returns normally instead of
longjmp'ing. So `error("msg")` inside a coroutine body looks like
a clean return to `resume` — the message gets printed to chat as
a side effect and `resume` returns `true` with no values. Real VM
errors (calling `nil`, indexing `nil`, etc.) still propagate
through the standard `false, msg` path.
### `coroutine.yield(...)`
Suspends the current coroutine. Whatever's passed to `yield`
becomes the return values of the matching `coroutine.resume`
call. When the coroutine is next resumed, the args passed to
`resume` become the return values of `yield`.
Raises an error if called outside a coroutine
(`attempt to yield across metamethod/C-call boundary`) or from
a C function (`cannot yield a C function`).
### `coroutine.status(co)`
Returns the coroutine's state as a string:
| Value | Meaning |
|-------|---------|
| `"running"` | `co` is the currently-running coroutine (i.e. the same thread that called `status`). |
| `"suspended"` | `co` is paused — either initial (never resumed) or mid-yield. |
| `"dead"` | `co`'s body has returned or errored. |
The standard Lua `"normal"` state — a coroutine that resumed a
deeper coroutine and is now an ancestor on the call chain — is
collapsed into `"suspended"`. Detecting it cleanly requires
walking the global thread list, and the case is near-useless for
addon-level logic.
### `coroutine.wrap(fn)`
Equivalent to `coroutine.create(fn)` followed by returning a
closure that calls `coroutine.resume(co, ...)` on each invocation
— but propagates errors as raised exceptions (via `lua_error`)
instead of returning `false, msg`. Lets you use a coroutine as
if it were an ordinary function:
```lua
local gen = coroutine.wrap(function()
for i = 1, 5 do coroutine.yield(i) end
end)
print(gen()) -- 1
print(gen()) -- 2
print(gen()) -- 3
```
Calling the wrapped function after the body has completed raises
`cannot resume dead coroutine`. The canonical "iterator that
terminates cleanly through `for ... in`" idiom is to yield an
explicit `nil` at the end so the for-loop's nil sentinel fires:
```lua
local function range(from, to)
return coroutine.wrap(function()
for i = from, to do coroutine.yield(i) end
coroutine.yield(nil)
end)
end
for n in range(1, 5) do print(n) end
```
### Async pattern (`RunAsync` + `C_Timer.After`)
Coroutines pair naturally with `C_Timer.After` for chunked
"do some work, wait a frame, do more" loops. The canonical
scheduler:
```lua
local function RunAsync(fn)
local co = coroutine.create(fn)
local function step()
local ok, delay = coroutine.resume(co)
if not ok or coroutine.status(co) == "dead" then return end
C_Timer.After(delay or 0, step)
end
step()
end
RunAsync(function()
for i = 1, 100 do
DoExpensiveWork(i)
coroutine.yield(0) -- next frame
end
coroutine.yield(0.5) -- pause half a second
DoFinalWork()
end)
```
`coroutine.yield(n)` returns `n` to the scheduler, which schedules
the next `step` after that many seconds. `yield(0)` is the
"resume on the next frame" form (since `C_Timer.After(0, ...)`
fires on the next OnUpdate tick). `RunNextFrame(cb)` in
[`AddOns/!!!ClassicAPI/Util/FunctionUtil.lua`](../AddOns/!!!ClassicAPI/Util/FunctionUtil.lua)
wraps the same primitive for the non-coroutine case.
## CVar
### `C_CVar.GetCVarBool(cvar)`
@@ -2022,6 +2172,75 @@ quest invalidated, etc.). Only fires on a real successful turn-in.
> done, quest removed from log). Hooking the packet handler gives
> us the same signal modern WoW's event uses.
### `UNIT_FACTION` event (fire-coverage fix)
`UNIT_FACTION` already exists in the vanilla event table — addons can
register for it — but the 1.12 engine **never fires it** on several
paths where modern WoW (3.3.5+) does. We restore the modern firing
semantics so addons that observe `UNIT_FACTION` stop missing state
changes.
Payload is unchanged from modern: `arg1` is the unit token string,
always `"player"` for these paths (you can only toggle / sync rep on
the local player).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FACTION")
f:SetScript("OnEvent", function()
if event == "UNIT_FACTION" and arg1 == "player" then
RefreshFactionUI()
end
end)
```
**Paths that now fire `UNIT_FACTION("player")`:**
| Path | Trigger | Detection |
|------|---------|-----------|
| `FactionToggleAtWar` | Lua call to toggle AT_WAR | Slot's flag byte changed (skips PEACE_FORCED no-ops, looting-blocked attempts, standing-blocked attempts) |
| `SetFactionInactive` / `SetFactionActive` | Lua call to toggle INACTIVE | Slot's flag byte changed |
| `SMSG_SET_FACTION_STANDINGS` | Server rep-change push (kills, quest turn-ins, etc.) | Any byte in the 64-entry flag array changed — catches AT_WAR side-effect flips when standing crosses the -3000 threshold |
| `SMSG_SET_FACTION_ATWAR` | Server force-change of AT_WAR (faction reset, server-side state push) | Unconditional after byte write |
| `SMSG_INITIALIZE_FACTIONS` | Server initial faction sync at login | Unconditional after table populated |
The last one fixes the case where addons relying on `UNIT_FACTION`
(rather than `PLAYER_LOGIN` / `VARIABLES_LOADED`) miss the initial
load entirely on vanilla.
**Not yet covered:**
- Unit-faction-template changes for *non-player* units (mind control,
charm, server-side faction scripts). Modern WoW fires
`UNIT_FACTION` on the affected unit token; the vanilla broad helper
isn't hooked here yet. Mostly affects nameplate / threat addons in
rare encounter mechanics.
**Implementation notes**
`FactionToggleAtWar` / `SetFactionInactive` / `SetFactionActive`'s
inner setters (`FUN_004D5FD0`, `FUN_004D60F0`) write to the rep slot's
flag byte and send the corresponding CMSG, but never call the
engine's "fire UNIT_FACTION on this unit" dispatcher. 3.3.5 added a
`FUN_0071F8F0(player, 0)` call to both paths whose inner
`FUN_0060BF10(playerGUID, UNIT_FACTION_id)` broadcasts the event for
every unit token referencing the local player.
Since `FactionToggleAtWar` only ever runs for the player and the only
unit token resolving to the player is `"player"`, we fire
`UNIT_FACTION("player")` directly via the engine's printf-style
dispatcher (`FUN_FIRE_EVENT`) — same observable result without
re-deriving the vanilla broad helper. We resolve `UNIT_FACTION`'s
event-table slot lazily by name (`Event::Custom::LookupByName`) so we
stay correct against any DLL combination that reshuffles the table.
For the two SMSG-bound flag-flip paths (`SetFactionStandings`,
`SetFactionInactive`-but-from-server), we snapshot the 64-entry rep
slot flag-byte array before the original handler runs and compare
after — single fire if any byte changed. The bulk snapshot is 64
byte reads (cheap) and naturally handles the case where one packet
carries multiple rep updates whose threshold crossings ripple AT_WAR
state across unrelated slots.
### `UPDATE_SHAPESHIFT_FORM` event
Fires whenever the local player's shapeshift form changes — entering
@@ -3568,6 +3787,143 @@ directly off the CGItem's m_objectFields descriptor at +0x20
Equivalent to the legacy global `GetItemCount` (since 3.0) and the
modern `C_Item.GetItemCount` introduced in 10.x.
### `C_Item.GetItemData(itemLocation)` / `C_Item.GetItemDataByID(item)`
ClassicAPI-only kitchen-sink reader: returns a single table with every
field we can extract from the cached `ItemStats_C` record, so addons
that need more than a couple of fields don't have to chain a dozen
`C_Item.*` calls. Saves both stack churn and the cost of redoing the
cache lookup for every accessor.
```
data = C_Item.GetItemDataByID(item) -- numeric id / "item:NNN" / chat link
data = C_Item.GetItemData(itemLocation) -- {bagID,slotIndex} or {equipmentSlotIndex}
```
Returns `nil` if the input doesn't resolve to a valid itemID or the
item isn't cached yet. Does **not** warm the cache — same passive-
reader contract as
[`C_Item.GetItemInfoInstant`](#c_itemgetiteminfoinstantitem).
Callers that need to wait for cache fill should call
[`C_Item.RequestLoadItemDataByID`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation)
and listen for `ITEM_DATA_LOAD_RESULT`.
Returned table:
| Key | Type | Source / notes |
|-----|------|---------------|
| `itemID` | number | Echoed from input. |
| `name` | string | `m_name[0]` — localized display name. |
| `description` | string \| nil | `m_description`. Omitted when empty. |
| `icon` | string \| nil | `"Interface\\Icons\\<name>"`. Omitted when no icon. |
| `displayInfoID` | number | `m_displayInfoID` → `ItemDisplayInfo.dbc` row. |
| `quality` | number | 0..5 — see `LE_ITEM_QUALITY_*` enum. |
| `classID` | number | `m_class` — `ItemClass.dbc` row. |
| `subclassID` | number | `m_subClass` — `ItemSubClass.dbc` row. |
| `className` | string | Localized class name (e.g. `"Weapon"`). |
| `subclassName` | string | Localized subclass name (e.g. `"One-Handed Swords"`). |
| `inventoryType` | number | `m_inventoryType` — raw integer. |
| `equipLoc` | string | `"INVTYPE_*"` constant or `""` for non-equippable. |
| `bindType` | number | `m_bonding` — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest. |
| `flags` | number | Raw `m_flags` u32. |
| `isConjured` | bool | Flag bit `0x2`. |
| `isOpenable` | bool | Flag bit `0x4`. Same bit `C_Item.IsItemOpenable` reads. |
| `isLootable` | bool | Flag bit `0x10` — has loot generators (right-click loot). |
| `isWrapper` | bool | Flag bit `0x200` — gift-wrappable. |
| `maxStackSize` | number | `m_stackable`. |
| `maxCount` | number | `m_maxCount` — 0=unlimited, 1=unique, otherwise per-character cap. |
| `containerSlots` | number | `m_containerSlots` — bag slot count, 0 for non-bags. |
| `bagFamily` | number | `m_bagFamily` converted to modern bitmask (`1 << (id-1)`). |
| `buyPrice` | number | Vendor buy price in copper. |
| `sellPrice` | number | Vendor sell price in copper. |
| `itemLevel` | number | Base ilvl from `ItemSparse`. |
| `requiredLevel` | number | Minimum character level. |
| `requiredSkill` | number | `SkillLine.dbc` row, 0=none. |
| `requiredSkillRank` | number | Skill rank required. |
| `requiredSpell` | number | `Spell.dbc` row required to learn / use, 0=none. |
| `requiredHonorRank` | number | PvP honor rank required, 0=none. |
| `requiredCityRank` | number | Reserved; 0 for vanilla. |
| `requiredFaction` | number | `Faction.dbc` row, 0=none. |
| `requiredFactionRank` | number | Reputation tier (Friendly/Honored/…). |
| `allowableClass` | number | Class bitmask, `-1` = all classes. |
| `allowableRace` | number | Race bitmask, `-1` = all races. |
| `armor` | number | Armor value. |
| `block` | number | Shield block value. |
| `maxDurability` | number | 0 for items without durability. |
| `stats` | table | Sparse `{ [statType] = statValue, … }`. Empty for items without stat allocations. |
| `resistanceHoly` | number | Holy resistance. |
| `resistanceFire` | number | Fire resistance. |
| `resistanceNature` | number | Nature resistance. |
| `resistanceFrost` | number | Frost resistance. |
| `resistanceShadow` | number | Shadow resistance. |
| `resistanceArcane` | number | Arcane resistance. |
| `damageMin` | table | 5-element float array — weapon min damage per damage slot. |
| `damageMax` | table | 5-element float array. |
| `damageType` | table | 5-element integer array — damage school per slot. |
| `delay` | number | Weapon swing time in ms. |
| `ammoType` | number | Ammo subclass for ranged weapons. |
| `rangedModRange` | number | Range modifier for bows/guns. |
| `spells` | table | Sparse array of `{id, trigger, charges, cooldown, category, categoryCooldown}` records — one per non-empty spell slot (up to 5). |
| `useSpellID` | number \| nil | Convenience — the spellID of the first slot with trigger=ON_USE (0). Omitted when the item has none. Same value [`C_Item.GetItemSpell`](#c_itemgetitemspellitem) returns. |
| `lockID` | number | `Lock.dbc` row for the item's lock, 0=none. |
| `itemSet` | number | `ItemSet.dbc` row, 0=not part of a set. |
| `pageText` | number | `PageText.dbc` row for readable items. |
| `pageMaterial` | number | Book page material. |
| `languageID` | number | Language for in-game-readable books. |
| `startQuest` | number | `Quest` row started by right-clicking this item. |
| `material` | number | Material type (cloth/leather/metal etc. for hit sounds). |
| `sheath` | number | Weapon sheath style. |
| `randomProperty` | number | Random property template id. |
| `area` | number | `AreaTable.dbc` — bound area for area-locked items, 0=none. |
| `map` | number | `Map.dbc` — bound map for map-locked items, 0=none. |
```lua
local data = C_Item.GetItemDataByID(6948) -- Hearthstone
-- data.name == "Hearthstone"
-- data.quality == 1, data.classID == 15, data.subclassID == 0
-- data.bindType == 1 (BoP)
-- data.useSpellID == 8690 (the recall spell)
-- data.maxStackSize == 1, data.maxCount == 1
local sword = C_Item.GetItemDataByID(2092) -- Worn Shortsword
-- sword.equipLoc == "INVTYPE_WEAPONMAINHAND"
-- sword.damageMin[1] == 1.0, sword.damageMax[1] == 2.0, sword.delay == 1900
-- sword.stats == {} (no stat allocations on the starter sword)
```
**Cache state.** The function reads the same `DBCache_ItemStats`
network cache every other `C_Item.*` reader consults. Items already in
the player's inventory are typically populated by the engine's natural
prefetch before `PLAYER_ENTERING_WORLD` returns; for other items
(quest rewards, AH items, link hovers, etc.) the data lands once a
`SMSG_ITEM_QUERY_SINGLE_RESPONSE` arrives. Use
`C_Item.RequestLoadItemDataByID` if you specifically need to trigger
the load.
**Stats encoding.** The `stats` table is keyed by the **ItemModType**
enum (vanilla's encoding):
| Key | Meaning |
|-----|---------|
| 0 | Mana |
| 1 | Health |
| 3 | Agility |
| 4 | Strength |
| 5 | Intellect |
| 6 | Spirit |
| 7 | Stamina |
Higher-numbered slots (consumable per-second-regen, defense rating,
hit/crit/dodge/parry chance, weapon/spell-power) only appear on items
the server actually carries — vanilla 1.12 mostly uses 0..7. Empty
slots (both type and value zero) are omitted, so iterating `stats`
yields only the stats actually allocated.
No equivalent in modern WoW — `GetItemInfo` returns the 14-tuple
piecemeal and `C_Item.GetItemInfo` adds a few more. ClassicAPI's
`GetItemData` is a single-call superset useful for backporting tooltip
addons and item-data caches.
### `C_Item.GetItemFamily(item)`
Returns the BagFamily bitmask for an item — i.e., what kind of
@@ -3678,8 +4034,8 @@ end
Modern-style accessor for the always-available subset of item info — the
fields that depend only on classification, not on player-specific state.
Returns immediately from the client-side item cache; no server round-trip,
no async polling. Returns `nil` if the item isn't in cache.
Synchronous, side-effect-free: peeks the client-side item cache and
returns whatever it has without warming or queueing.
Accepts a numeric `itemID` or a string containing `"item:NNN"` (matches both
the bare `"item:1234"` shorthand and full chat links like
@@ -3693,6 +4049,13 @@ Returns seven values:
itemID, itemType, itemSubType, itemEquipLoc, icon, classID, subClassID
```
The `itemID` return is always populated for any input that resolves to
a positive integer (so it's safe to use the function as an ID extractor
on a link without first warming the cache). The remaining six fields
come from the cache record when present — when the item isn't cached
yet they're all `nil`, but the call still returns a 7-tuple so the
positional shape is preserved.
- `itemType` / `itemSubType` are the localized class / subclass names
(e.g. `"Weapon"` / `"One-Handed Swords"`), read from `ItemClass.dbc` and
`ItemSubClass.dbc`.
@@ -3706,15 +4069,27 @@ itemID, itemType, itemSubType, itemEquipLoc, icon, classID, subClassID
```lua
local id, type, subtype, equipLoc, icon, classID, subClassID
= C_Item.GetItemInfoInstant(6948) -- Hearthstone
= C_Item.GetItemInfoInstant(6948) -- Hearthstone, cached
-- type="Miscellaneous", subtype="Junk", equipLoc="",
-- icon="Interface\\Icons\\INV_Misc_Rune_01", classID=15, subClassID=0
local id = C_Item.GetItemInfoInstant("|cff...|Hitem:6948:0:0:0|h[Hearthstone]|h|r")
-- id == 6948 even when the cache is cold — the call parses the link
-- to extract the ID without consulting the cache.
```
The actual class/subclass values reflect 1.12.1's data, which differs from
modern WoW. For example, vanilla had no Cloth subclass under Trade Goods —
Silk Cloth lives at `(7, 0)` in this client, not the modern `(7, 5)`.
> **No auto-warmup.** Unlike `GetItemInfo` or `C_Item.GetItemNameByID`,
> `GetItemInfoInstant` does not trigger a network query on a cache
> miss. The "Instant" name is contractual — callers that need the
> cache populated should use
> [`C_Item.RequestLoadItemDataByID`](#c_itemrequestloaditemdatabyiditem--c_itemrequestloaditemdataitemlocation)
> explicitly, or just call `GetItemInfo` (which warms via the
> `Script_GetItemInfo` hook).
### `C_Item.GetItemInventoryType(itemLocation)` / `C_Item.GetItemInventoryTypeByID(item)`
Returns the numeric `Enum.InventoryType` straight off the cache
@@ -4241,16 +4616,19 @@ was already cached (so polling code paths still work), asynchronously
fired when the engine's SMSG response handler completes a network
fetch.
> **Vanilla quirk:** the engine has no native bool format code — `success`
> arrives as `1`/`0` (number), not `true`/`false`. Idiomatic check is
> `if success == 1 then ...`.
> **`success` is `1` or `nil`.** The engine's printf-style event
> dispatcher has no `%b` token, so we encode the boolean as `1` for
> success / `nil` for failure (leaning on `lua_pushstring(NULL)` →
> `lua_pushnil`). `if success then …` distinguishes them correctly —
> `nil` is falsy, `1` is truthy. Same encoding as `GET_ITEM_INFO_RECEIVED`
> and `QUEST_DATA_LOAD_RESULT`.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("ITEM_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
-- vanilla 1.12: event payload is in `arg1`, `arg2`, ... globals
if event == "ITEM_DATA_LOAD_RESULT" and arg2 == 1 then
if event == "ITEM_DATA_LOAD_RESULT" and arg2 then
local _, type = C_Item.GetItemInfoInstant(arg1)
-- ...
end
@@ -4258,6 +4636,21 @@ end)
C_Item.RequestLoadItemDataByID(2589)
```
> **Engine-aligned cache strategy.** `RequestLoadItemData` does NOT
> eagerly create a cache entry. Empirically (e.g. pfQuest calling it
> at addon-load / PLAYER_ENTERING_WORLD against a cleared WDB), pre-
> creating the entry races the engine's natural inventory prefetch
> and can leave the item permanently pending — the engine sees the
> entry exists and skips its own `SMSG_ITEM_QUERY_SINGLE`, while our
> early query gets dropped. Instead we track the itemID and hook the
> engine's `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`:
> when the engine fills any entry, we sweep tracked items and fire
> `ITEM_DATA_LOAD_RESULT` for matches. For items the engine doesn't
> prefetch (quest rewards, AH browse targets, etc.) we escalate after
> ~60 ticks by calling `CacheFetch` ourselves — by then engine state
> has settled and our query goes through normally. Hard timeout at
> ~1200 ticks (~20–40s) fires `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
### `C_Item.UnlockAllItems()`
Sweep clear of every CGItem the engine knows about — same primitive
@@ -5626,9 +6019,11 @@ cache. Synchronously fired when the data was already cached
`SMSG_QUEST_QUERY_RESPONSE` lands when the engine had to round-trip to the
server.
> **Vanilla quirk:** the engine has no native bool format code — `success`
> arrives as `1`/`0` (number), not `true`/`false`, just like
> `ITEM_DATA_LOAD_RESULT`. Idiomatic check is `if success == 1 then ...`.
> **`success` is `1` or `nil`.** The engine's printf-style event
> dispatcher has no `%b` token, so we encode the boolean as `1` for
> success / `nil` for failure. `if success then ...` works as expected
> (`nil` falsy, `1` truthy). Same encoding as `ITEM_DATA_LOAD_RESULT`
> and `GET_ITEM_INFO_RECEIVED`.
> **Vanilla limitation:** for *invalid* questIDs (ones the server doesn't
> have), the 1.12 server silently drops the query — it doesn't send a
+9 -2
View File
@@ -3,7 +3,9 @@
Catalogue of the Lua C API functions linked into the client. Vanilla WoW is
built against **Lua 5.0** (not 5.1), and the entire API plus the auxiliary
library lives in a contiguous block of `.text` from roughly `0x006F2000` to
`0x006F5100`. ~120 functions; the public C API is the core of that.
`0x006F5100`. ~120 functions; the public C API is the core of that. The
thread / coroutine machinery (`lua_resume`, `lua_yield`, the inner
`luaE_newthread`) lives slightly higher at `0x006F6000`–`0x006F6900`.
**Cross-verified against two binaries:**
@@ -75,6 +77,8 @@ context but not byte-by-byte verified.
| VA | Function | Conv. | Notes | Conf |
|--------------|-------------------------|----------|--------------------------------------------------------------|------|
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
| `0x006F3070` | `lua_gettop` | fastcall | `(top - base) >> 4` | ★ |
| `0x006F3080` | `lua_settop` | fastcall | | ✓ |
| `0x006F30D0` | `lua_remove` | fastcall | shift-down loop + decr_top; previously misidentified as `lua_pushvalue` | ★ |
@@ -155,7 +159,10 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4290` | *internal CFunction trampoline* | | called via `lua_cpcall` | ★ |
| `0x006F4320` | `lua_load` | fastcall | reader / loader entry | ★ |
| `0x006F4370` | `lua_dump` | fastcall | requires tag 6 + `Closure.isC == 0` | ★ |
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
| `0x006F6B10` | *internal* `luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
### Misc / GC / version
+9 -1
View File
@@ -48,7 +48,15 @@ namespace Lua {
F(PCall, lua_pcall, LUA_PCALL) \
F(Next, lua_next, LUA_NEXT) \
F(Type, lua_type, LUA_TYPE) \
F(Error, lua_error, LUA_ERROR)
F(Error, lua_error, LUA_ERROR) \
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
F(XMove, lua_xmove, LUA_X_MOVE) \
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+31 -1
View File
@@ -49,6 +49,8 @@ constexpr int TYPE_NUMBER = 3;
constexpr int TYPE_STRING = 4;
constexpr int TYPE_TABLE = 5;
constexpr int TYPE_FUNCTION = 6;
constexpr int TYPE_USERDATA = 7;
constexpr int TYPE_THREAD = 8;
using lua_isnumber_t = bool(__fastcall *)(void *L, int index);
using lua_isstring_t = bool(__fastcall *)(void *L, int index);
@@ -76,7 +78,27 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
using lua_next_t = int(__fastcall *)(void *L, int idx);
using lua_type_t = int(__fastcall *)(void *L, int index);
using lua_error_t = void(__cdecl *)(void *L, const char *);
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
// and throws. Existing callers pass a single literal (no `%`), which
// is fine because cdecl tolerates extra-arg-less calls. New callers
// forwarding user-supplied error strings should use `Error(L, "%s",
// userMsg)` to avoid format-string interpretation of `%` in the
// message body.
using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
// infers `void` because the inner call's value just flows through.
// We declare the return type honestly here; callers that don't want
// to rely on the calling convention preserving eax can read the
// thread back from L's top via `ToPointer(L, -1)`.
using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -110,6 +132,14 @@ extern const lua_pcall_t PCall;
extern const lua_next_t Next;
extern const lua_type_t Type;
extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
extern const lua_iscfunction_t IsCFunction;
extern const lua_xmove_t XMove;
extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+189
View File
@@ -1202,6 +1202,10 @@ enum Offsets {
// always at index 0 (some Turtle WoW custom items put it later).
OFF_ITEMSTATS_SPELL_ID = 0x11C, // u32[5] — spell ID per slot
OFF_ITEMSTATS_SPELL_TRIGGER = 0x130, // u32[5] — trigger code per slot
OFF_ITEMSTATS_SPELL_CHARGES = 0x144, // i32[5] — charges per slot (-1 = infinite)
OFF_ITEMSTATS_SPELL_COOLDOWN = 0x158, // u32[5] — cooldown ms per slot
OFF_ITEMSTATS_SPELL_CATEGORY = 0x16C, // u32[5] — category (groups shared cooldowns)
OFF_ITEMSTATS_SPELL_CATEGORY_CD = 0x180, // u32[5] — category cooldown ms
ITEMSTATS_SPELL_SLOT_COUNT = 5,
ITEM_SPELLTRIGGER_ON_USE = 0,
@@ -1213,6 +1217,63 @@ enum Offsets {
// members in VanillaHelpers's `ItemStats_C` definition.
OFF_ITEMSTATS_MAX_COUNT = 0x5C,
// Remaining `ItemStats_C` fields used by `C_Item.GetItemData(ByID)`.
// Offsets derived from VanillaHelpers's `struct ItemStats_C`
// definition (`C:\Git\VanillaHelpers\src\Game.h`), sized to match
// the verified anchors above (`+0x60 m_stackable`, `+0x1D0
// m_bagFamily`, `+0x1AC m_lockID`, etc.). Block follows the same
// ordering as the struct so it's easy to cross-reference.
OFF_ITEMSTATS_BUY_PRICE = 0x24, // u32
OFF_ITEMSTATS_ALLOWABLE_CLASS = 0x30, // u32 — class bitmask, -1 = all
OFF_ITEMSTATS_ALLOWABLE_RACE = 0x34, // u32 — race bitmask, -1 = all
OFF_ITEMSTATS_REQUIRED_LEVEL = 0x3C, // u32
OFF_ITEMSTATS_REQUIRED_SKILL = 0x40, // u32 — SkillLine.dbc row
OFF_ITEMSTATS_REQUIRED_SKILL_RANK = 0x44, // u32
OFF_ITEMSTATS_REQUIRED_SPELL = 0x48, // u32 — Spell.dbc row
OFF_ITEMSTATS_REQUIRED_HONOR_RANK = 0x4C, // u32
OFF_ITEMSTATS_REQUIRED_CITY_RANK = 0x50, // u32
OFF_ITEMSTATS_REQUIRED_FACTION = 0x54, // u32 — Faction.dbc row
OFF_ITEMSTATS_REQUIRED_FACTION_RANK = 0x58, // u32 — Friendly/Honored/etc.
OFF_ITEMSTATS_STAT_TYPE = 0x68, // u32[10] — ItemModType enum per slot
OFF_ITEMSTATS_STAT_VALUE = 0x90, // i32[10] — magnitude per slot
ITEMSTATS_STAT_SLOT_COUNT = 10,
OFF_ITEMSTATS_DAMAGE_MIN = 0xB8, // f32[5]
OFF_ITEMSTATS_DAMAGE_MAX = 0xCC, // f32[5]
OFF_ITEMSTATS_DAMAGE_TYPE = 0xE0, // u32[5] — schools per damage slot
ITEMSTATS_DAMAGE_SLOT_COUNT = 5,
OFF_ITEMSTATS_ARMOR = 0xF4, // u32
OFF_ITEMSTATS_RES_HOLY = 0xF8, // u32 (six resists contiguous)
OFF_ITEMSTATS_RES_FIRE = 0xFC,
OFF_ITEMSTATS_RES_NATURE = 0x100,
OFF_ITEMSTATS_RES_FROST = 0x104,
OFF_ITEMSTATS_RES_SHADOW = 0x108,
OFF_ITEMSTATS_RES_ARCANE = 0x10C,
OFF_ITEMSTATS_DELAY = 0x110, // u32 — weapon swing time (ms)
OFF_ITEMSTATS_AMMO_TYPE = 0x114, // u32 — ammo subclass for ranged
OFF_ITEMSTATS_RANGED_MOD_RANGE = 0x118, // f32 — range modifier on bows/guns
OFF_ITEMSTATS_BONDING = 0x194, // u32 — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest
OFF_ITEMSTATS_DESCRIPTION = 0x198, // char * — flavor text (locale-applied)
OFF_ITEMSTATS_PAGE_TEXT = 0x19C, // u32 — PageText.dbc row (readable books)
OFF_ITEMSTATS_LANGUAGE_ID = 0x1A0, // u32 — language for book text
OFF_ITEMSTATS_PAGE_MATERIAL = 0x1A4, // u32 — book material
OFF_ITEMSTATS_START_QUEST = 0x1A8, // u32 — questID started by right-click
OFF_ITEMSTATS_MATERIAL = 0x1B0, // i32 — material type
OFF_ITEMSTATS_SHEATH = 0x1B4, // u32 — weapon sheath style
OFF_ITEMSTATS_RANDOM_PROPERTY = 0x1B8, // i32 — random property template
OFF_ITEMSTATS_BLOCK = 0x1BC, // u32 — shield block value
OFF_ITEMSTATS_MAX_DURABILITY = 0x1C4, // u32
OFF_ITEMSTATS_AREA = 0x1C8, // u32 — bound area (AreaTable.dbc)
OFF_ITEMSTATS_MAP = 0x1CC, // u32 — bound map (Map.dbc)
// `m_flags` bits we surface as decoded booleans. The OPENABLE bit
// (0x4) is verified by the tooltip-builder's reader at 0x0052E323;
// the others are documented in the standard 1.12 protocol
// (CMaNGOS / TrinityCore `ITEM_FLAG_*`) and exposed as-is. Add
// verification anchors as consumers materialize.
ITEM_FLAG_CONJURED = 0x2,
ITEM_FLAG_LOOTABLE = 0x10,
ITEM_FLAG_WRAPPER = 0x200,
// `Spell.dbc` `Name[9]` field at record +0x1E0 (9-locale string
// array, indexed by `[VAR_LOCALE_INDEX]`). Used by `GetItemSpell`
// to push the spell name half of its return.
@@ -1723,6 +1784,10 @@ enum Offsets {
// Note: the bit's MEANING is "can't toggle" — `canToggleAtWar` is
// its inverse, NOT its value.
REP_SLOT_FLAG_PEACE_FORCED = 0x10,
// INACTIVE flag — set by `Script_SetFactionInactive` (the "Inactive"
// collapsible category in FactionFrame). Toggles via
// `FUN_FACTION_SET_INACTIVE`; same byte as AT_WAR and PEACE_FORCED.
REP_SLOT_FLAG_INACTIVE = 0x20,
MAX_REP_SLOTS = 64,
// Static reaction-band threshold tables, indexed by reaction band
@@ -1742,6 +1807,81 @@ enum Offsets {
// against `FUN_004D5620(slot)`'s return.
OFF_CGPLAYER_INFO_WATCHED_REP_LIST_ID = 0x10C4,
// `FactionSetAtWar(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. The sole worker behind
// `Script_FactionToggleAtWar` (`0x004D6950`); the script wrapper just
// reads the current at-war flag, negates it, and tail-calls here.
//
// Validates (only when turning OFF) that current standing ≥ -3000,
// resolves factionID → repListID, bails if the player is in a loot
// session (UNIT_FIELD_FLAGS bit `0x80000`, error code `0x1A1`),
// mutates the rep-slot flag byte (`AT_WAR` gated by `PEACE_FORCED`),
// and sends `CMSG_SET_FACTION_ATWAR` (opcode `0x125`).
//
// **Does NOT fire `UNIT_FACTION`** — a vanilla engine omission. 3.3.5
// added a `FUN_0071F8F0(player, 0)` call here, whose inner
// `FUN_0060BF10(playerGUID, UNIT_FACTION_id)` dispatches the event
// for every unit token referencing the player. We polyfill by
// hooking this function and firing `UNIT_FACTION("player")` post-
// original (see `src/faction/UnitFactionPolyfill.cpp`).
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
// (`0x004D6A00`, clears it) — both script wrappers tail-call here.
//
// Resolves factionID → repListID via `FUN_004D5600`, mutates the
// rep-slot flag byte (sets/clears `INACTIVE` = `0x20`), writes via
// `FUN_004D5FC0`, sends `CMSG_SET_FACTION_INACTIVE` (opcode `0x317`),
// then calls `FUN_004D5C40` (recompute displayed-list, fire
// `UPDATE_FACTION` event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — same engine omission as the at-
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires when the server force-changes the player's
// at-war state on a faction (e.g., a flag the player can't toggle
// changes server-side, or faction reset). Reads `(u32 repListID,
// u8 flags)`, mutates AT_WAR on the slot accordingly, writes via
// `FUN_004D5FC0`, then fires `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — 3.3.5 does (unconditionally
// after the byte write). Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_SET_FACTION_ATWAR = 0x004D56B0,
// SMSG_INITIALIZE_FACTIONS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once at login from the SMSG dispatcher. Reads
// a count, then loops `(u8 flags, i32 standing)` pairs into the
// 64-entry rep slot array: `FUN_004D5FC0(slot, flags)` +
// `FUN_004D6330(slot, standing, 0 /*notify*/)`. Trailing
// `FUN_004D5430` rebuilds the displayed-list snapshot, then fires
// `UPDATE_FACTION` (event `0x174`).
//
// **Does NOT fire `UNIT_FACTION`** — modern WoW does. Addons that
// observe UNIT_FACTION miss the initial sync entirely in vanilla.
// Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
FUN_SMSG_INITIALIZE_FACTIONS = 0x004D5640,
// SMSG_SET_FACTION_STANDINGS handler — `__stdcall(uint32_t opcode,
// void *packet)`. Fires once per server-pushed rep change (kills,
// quest turn-ins, etc.). Reads count, loops `(flags, standing)`
// pairs, calling `FUN_004D6330(slot, standing, 1 /*notify*/)` which
// routes through `FUN_REPUTATION_FIRE_NOTIFY` for the
// `CHAT_MSG_COMBAT_FACTION_CHANGE` dispatch.
//
// Also mutates AT_WAR / VISIBLE flag bits as standing crosses
// thresholds (e.g., recovering to -2999 clears the force-set AT_WAR
// from "below Hated"). **Vanilla doesn't fire `UNIT_FACTION` for
// these side-effect flag flips**; modern 3.3.5 does. Polyfilled in
// `src/faction/UnitFactionPolyfill.cpp` — snapshots the 64-entry
// flag-byte array before/after and fires a single
// `UNIT_FACTION("player")` if any bit changed.
FUN_SMSG_SET_FACTION_STANDINGS = 0x004D5760,
// `__fastcall(ecx = factionID, edx = signedDelta)`. This is the
// engine's "reputation changed, fire the chat event" notify
// helper — it formats the localized chat message and dispatches
@@ -2385,6 +2525,55 @@ enum Offsets {
LUA_NEXT = 0x6F4450,
LUA_ERROR = 0x6F4940,
// `lua_xmove(from, to, n)` — moves the top `n` TValues from one
// thread to another. Decrements `from->top` by `n*16`, copies each
// 16-byte TValue, increments `to->top`. Needed by the `coroutine.*`
// trampolines to shuttle args/returns between the main state and a
// suspended coroutine.
LUA_X_MOVE = 0x6F2F80,
// `lua_newthread(L)` — does GC check, calls inner `luaE_newthread`
// at `0x006F6B10` to allocate a new state, pushes it onto L's stack
// with type tag 8 (LUA_TTHREAD). Ghidra reports the return as
// `void` (decompiler limitation — the inner call's eax flows
// through), so callers should retrieve the new state via
// `ToPointer(L, -1)` rather than relying on the return value.
LUA_NEW_THREAD = 0x6F3030,
// `lua_resume(L_co, nargs)` — runs a coroutine. Zero internal
// callers in the engine (coroutine library was stripped from the
// standard-library registration tables at build time); linker
// kept the symbol so it's callable from C. Goes through
// `luaD_rawrunprotected` at `0x006F5DAC` for setjmp/longjmp
// safety; inner static `resume` helper lives at `0x006F67B0`.
LUA_RESUME = 0x6F6620,
// `lua_yield(L, nresults)` — flags the current CallInfo to yield
// and returns -1. Sets a `0x10` bit on the CI->state field rather
// than writing `L->status` directly in this build. Zero internal
// callers (same reason as `lua_resume`). Errors with
// "attempt to yield across metamethod/C-call boundary" or
// "cannot yield a C function" when called from the wrong context.
LUA_YIELD = 0x6F6860,
// `lua_topointer(L, idx)` — returns the pointer value of a
// pointer-valued TValue: type 5 (table), 6 (function), 8 (thread)
// return `tvalue[2]` directly; type 2 (lightuserdata) returns its
// value; type 7 (userdata) returns the userdata payload (record +
// 0x10). Returns 0 for anything else. We use it as `lua_tothread`
// — for a known-thread TValue, returns the embedded `lua_State *`.
LUA_TO_POINTER = 0x6F3790,
// `luaL_argerror(L, narg, msg)` — formats "bad argument #%d to
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
// (Lua 5.0 only allows Lua-defined functions as coroutine bodies).
LUA_IS_C_FUNCTION = 0x6F34A0,
// `lua_tothread(L, idx)` — returns the embedded `lua_State *` for
// type-8 (LUA_TTHREAD) TValues, NULL for anything else. Used by
// the `coroutine` library to extract the coroutine pointer from a
// stack slot.
LUA_TO_THREAD = 0x6F3770,
// Global `lua_State *`. The engine keeps one main thread state here; we
// read it on demand in helpers that run outside a Lua callback (e.g.
// RegisterTableFunction during LoadScriptFunctions).
+1 -1
View File
@@ -40,7 +40,7 @@
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
// reads each `.lua` file through the same hooked read path —
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
+11 -4
View File
@@ -110,6 +110,13 @@ const uint8_t *FindPlayerBuffEntry(uint32_t spellID) {
// what Lua's `GetTime()` returns (engineMs * 0.001), so converting
// `expirationMs * 0.001` lands directly on the same timeline addons
// compare against on the Lua side. No epoch reconciliation needed.
//
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
// days of system uptime. A signed read flips negative past that
// point, which broke a `<= 0` early-bail that was supposed to catch
// only the "no timing" (0) sentinel. The engine's own arithmetic
// works on the unsigned tick because both sides of `expiration -
// currentMs` wrap together.
double PlayerBuffExpirationSeconds(const uint8_t *entry) {
if (entry == nullptr)
return 0.0;
@@ -117,12 +124,12 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
if (slotCode < 0)
return 0.0;
auto *expirationTable = reinterpret_cast<const int *>(
auto *expirationTable = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
const int expirationMs = expirationTable[slotCode];
if (expirationMs <= 0)
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
return static_cast<double>(static_cast<uint32_t>(expirationMs)) * 0.001;
return static_cast<double>(expirationMs) * 0.001;
}
// Computes the spell's base duration in seconds via Spell.dbc →
+216
View File
@@ -0,0 +1,216 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
//
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
// type tag 8) but the `coroutine` global table was never registered.
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
// "Cross-binary reference" technique in CLAUDE.md).
#include "Game.h"
#include <cstdint>
namespace Coroutine {
// Lua 5.0 result codes returned by `lua_resume`.
static constexpr int LUA_OK = 0;
static constexpr int LUA_YIELD = 1;
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
// *(L+0x14) = *(L+0x28)
// *(L+0x0C) = **(L+0x28)
static constexpr uintptr_t OFF_L_CI = 0x14;
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
// the coroutine's body function is mid-execution (paused at a yield);
// when equal, no Lua function is on this thread's call stack.
static void *LuaCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
}
static void *LuaBaseCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
}
// Coroutine status codes returned by `CoStatus` below — fed into the
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
// also lets us label "unsuspended" states for resume's error path.
static constexpr int CO_RUNNING = 0;
static constexpr int CO_SUSPENDED = 1;
static constexpr int CO_NORMAL = 2;
static constexpr int CO_DEAD = 3;
static const char *const kStatusNames[] = {
"running",
"suspended",
"normal",
"dead",
};
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
// lbaselib.c, but we can't rely on `L->status` here: this build's
// `lua_yield` doesn't write status (it sets a CI state flag bit
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
// check fails. We use the `ci > base_ci` signal instead — true iff
// a Lua function is currently paused on this thread's call stack.
//
// State map:
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
// never resumed)
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
// co's stack empty; the ci comparison is what distinguishes them.
// "Normal" (coroutine that resumed a deeper coroutine — appears as
// the parent in the call chain) is rare and would require walking
// the global thread list; we collapse it into "suspended".
static int CoStatus(void *L, void *co) {
if (co == L)
return CO_RUNNING;
if (LuaCI(co) > LuaBaseCI(co))
return CO_SUSPENDED;
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
}
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
// (with values already xmove'd onto L), or -1 on error (with the
// error message left on L's stack).
//
// WoW's modified Lua doesn't follow the standard `lua_resume` status
// contract: it returns `0` for normal completion and `1` for both
// "yielded" AND "errored mid-execution". We disambiguate the two
// `s==1` cases by checking the call-stack depth — a yielded
// coroutine still has its body paused (`ci > base_ci`), while an
// errored coroutine has been unwound back to `base_ci`. Errors hit
// from the "dead/already-resumed" early-out (e.g. the inner
// `cannot resume dead coroutine` path) also land in `s != 0` with
// `ci == base_ci`, so the same branch covers them.
static int AuxResume(void *L, void *co, int nargs) {
const int st = CoStatus(L, co);
if (st != CO_SUSPENDED) {
Game::Lua::PushString(L, st == CO_DEAD
? "cannot resume dead coroutine"
: "cannot resume non-suspended coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
Game::Lua::XMove(co, L, nres);
return nres;
}
// Error path: engine left a single error message on co's stack.
Game::Lua::XMove(co, L, 1);
return -1;
}
static int __fastcall Script_Coroutine_Create(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_FUNCTION || Game::Lua::IsCFunction(L, 1)) {
Game::Lua::ArgError(L, 1, "Lua function expected");
return 0; // unreachable
}
Game::Lua::NewThread(L); // [arg1, NL]
void *NL = Game::Lua::ToThread(L, -1);
if (NL == nullptr) {
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
return 0; // unreachable
}
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
return 1;
}
static int __fastcall Script_Coroutine_Yield(void *L) {
return Game::Lua::Yield(L, Game::Lua::GetTop(L));
}
static int __fastcall Script_Coroutine_Resume(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
const int nargs = Game::Lua::GetTop(L) - 1;
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// Error message is on top of L's stack. Push `false` and
// shuffle it below the message: [..., msg] -> [..., false, msg].
Game::Lua::PushBool(L, false);
Game::Lua::Insert(L, -2);
return 2;
}
// Success: `r` values on top of L's stack. Push `true` and slide
// it into position so returns are [..., true, v1, v2, ..., vr].
Game::Lua::PushBool(L, true);
Game::Lua::Insert(L, -(r + 1));
return r + 1;
}
static int __fastcall Script_Coroutine_Status(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
return 1;
}
// `coroutine.wrap`'s returned closure. The captured coroutine lives
// in upvalue 1; each invocation forwards args via resume and either
// returns the yielded/returned values directly (no { ok, ... }
// wrapping like resume does) or re-raises on error.
static int __fastcall WrapAux(void *L) {
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
if (co == nullptr) {
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
return 0; // unreachable
}
const int nargs = Game::Lua::GetTop(L);
const int r = AuxResume(L, co, nargs);
if (r < 0) {
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
// a printf-format string + va args; we pass `%s` so any `%`
// characters in the message are treated as literal.
const char *msg = Game::Lua::ToString(L, -1);
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
return 0; // unreachable
}
return r;
}
static int __fastcall Script_Coroutine_Wrap(void *L) {
// Reuse cocreate to build the thread (validates arg, allocates,
// moves fn into the new state). Leaves the thread on top of L.
Script_Coroutine_Create(L);
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Coroutine
+17
View File
@@ -105,6 +105,23 @@ int Lookup(const char *name) {
return -1;
}
int LookupByName(const char *name) {
if (name == nullptr)
return -1;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = 0; i < count; ++i) {
const char *entryName = *reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
return i;
}
return -1;
}
void RetryClaims() {
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
+14
View File
@@ -47,8 +47,22 @@ struct AutoReserve {
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
int LookupByName(const char *name);
// Dispatches a custom event via the engine's printf-style event
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
+2 -28
View File
@@ -12,36 +12,10 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
#include "event/Custom.h"
namespace Event::Util {
// Walks the same `EventEntry` array that `Frame::RegisterEvent` strcmps
// against (base at `[VAR_EVENT_TABLE_BASE_PTR]`, count at
// `[VAR_EVENT_TABLE_COUNT]`, stride 0x10, name field at +0x00). This is
// the source of truth for "events the engine will accept registration
// for", so it picks up anything we've injected via `Event::Custom::Register`
// in addition to all built-in events.
static bool IsKnownEventName(const char *needle) {
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return false;
for (int i = 0; i < count; i++) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (std::strcmp(name, needle) == 0)
return true;
}
return false;
}
static int __fastcall Script_IsEventValid(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "Usage: C_EventUtils.IsEventValid(eventName)");
@@ -52,7 +26,7 @@ static int __fastcall Script_IsEventValid(void *L) {
Game::Lua::PushBool(L, 0);
return 1;
}
Game::Lua::PushBoolean(L, IsKnownEventName(needle));
Game::Lua::PushBoolean(L, Event::Custom::LookupByName(needle) >= 0);
return 1;
}
+248
View File
@@ -0,0 +1,248 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Restores `UNIT_FACTION("player")` event semantics that vanilla 1.12.1
// silently dropped from four engine paths. In 3.3.5 each of these calls
// `FUN_0071F8F0(player, 0) → FUN_0060BF10(playerGUID, UNIT_FACTION_id)`
// which broadcasts the event for every unit token referencing the
// local player; vanilla just returns. We hook each path and fire
// `UNIT_FACTION("player")` post-original via the engine's printf-style
// dispatcher.
//
// Why one file and not four: every hook does the same shape work
// (snapshot rep-slot state, run original, compare, fire). The shared
// `LookupEngineEvent` + `FireUnitFactionPlayer` helpers are the value;
// splitting four 30-line hooks into four files would just spread that
// helper around.
//
// Why hook the inner setters (vs. `Script_*` wrappers): same coverage,
// and any future C++ caller of the setter inherits the polyfill.
//
// Coverage map:
//
// 1. `FUN_FACTION_SET_AT_WAR` — `FactionToggleAtWar` toggles
// AT_WAR. Hook fires UNIT_FACTION if the flag byte changed.
//
// 2. `FUN_FACTION_SET_INACTIVE` — `SetFactionInactive` /
// `SetFactionActive` toggle INACTIVE. Same change-detection
// shape as at-war.
//
// 3. `FUN_SMSG_SET_FACTION_STANDINGS` — server rep update.
// The handler mutates AT_WAR / VISIBLE bits as standing crosses
// thresholds (recovery from below-Hated clears the force-set
// AT_WAR). Snapshots the 64-entry flag-byte array before/after
// and fires once if any byte changed.
//
// 4. `FUN_SMSG_INITIALIZE_FACTIONS` — server initial sync at login.
// Fires unconditionally after the engine populates the rep table.
// Lets addons that observe UNIT_FACTION (rather than
// PLAYER_LOGIN / VARIABLES_LOADED) see the initial state.
//
// 5. `FUN_SMSG_SET_FACTION_ATWAR` — server force-change of AT_WAR
// (e.g., faction reset, server-side state change). Fires
// unconditionally after the engine writes the slot byte, matching
// 3.3.5's behavior — server-pushed packets are infrequent and
// always indicate a state change worth signaling.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Faction::UnitFactionPolyfill {
namespace {
// Helpers ────────────────────────────────────────────────────────────
// `UNIT_FACTION`'s event-table slot. The engine's `RebuildEventTable`
// preserves slot order across reloads and other DLLs claim only NULL
// gaps, so this is stable once resolved. Lazy-init on first fire.
int s_unitFactionSlot = -1;
void FireUnitFactionPlayer() {
if (s_unitFactionSlot < 0)
s_unitFactionSlot = Event::Custom::LookupByName("UNIT_FACTION");
if (s_unitFactionSlot < 0)
return;
using FireEventFn_t = void(__cdecl *)(int eventID, const char *format, ...);
auto fire = reinterpret_cast<FireEventFn_t>(Offsets::FUN_FIRE_EVENT);
fire(s_unitFactionSlot, "%s", "player");
}
uint8_t ReadRepSlotFlags(int repListID) {
return *reinterpret_cast<uint8_t *>(
Offsets::VAR_PLAYER_REP_SLOTS +
repListID * Offsets::REP_SLOT_STRIDE +
Offsets::OFF_REP_SLOT_FLAGS);
}
// Resolves `factionID → repListID` (0..63 rep-slot index in
// `VAR_PLAYER_REP_SLOTS`) by reading the `+0x04` field on the
// `Faction.dbc` record directly. Same value the engine's helper at
// `0x004D5600` returns — it just dereferences the same DBC field after
// its own bounds check. Returns -1 for factionIDs that don't resolve
// to a record (out of range / empty slot) or are header categories
// (record stores -1 in the field).
int FactionIDToRepListID(int factionID) {
if (factionID <= 0)
return -1;
const auto *record = DBC::Record(Offsets::VAR_FACTION_DBC_RECORDS,
Offsets::VAR_FACTION_DBC_COUNT,
static_cast<uint32_t>(factionID));
if (record == nullptr)
return -1;
return *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
}
// Hook 1 — at-war setter ──────────────────────────────────────────────
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
SetAtWar_t s_setAtWar_o = nullptr;
void __fastcall SetAtWar_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setAtWar_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // engine bailed (looting / PEACE_FORCED no-op / standing block)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookAtWar{
Offsets::FUN_FACTION_SET_AT_WAR,
reinterpret_cast<void *>(&SetAtWar_h),
reinterpret_cast<void **>(&s_setAtWar_o)};
// Hook 2 — inactive setter ────────────────────────────────────────────
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
SetInactive_t s_setInactive_o = nullptr;
void __fastcall SetInactive_h(int factionID, int newState) {
const int repListID = FactionIDToRepListID(factionID);
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
s_setInactive_o(factionID, newState);
if (!slotValid)
return;
if (ReadRepSlotFlags(repListID) == beforeFlags)
return; // no-op (already in requested state)
FireUnitFactionPlayer();
}
const Game::HookAutoRegister _hookInactive{
Offsets::FUN_FACTION_SET_INACTIVE,
reinterpret_cast<void *>(&SetInactive_h),
reinterpret_cast<void **>(&s_setInactive_o)};
// Hook 3 — SMSG_SET_FACTION_STANDINGS ─────────────────────────────────
// Snapshot all 64 rep-slot flag bytes into a packed buffer. The engine's
// per-rep-change handler mutates AT_WAR / VISIBLE bits as standing
// crosses thresholds, so we need a delta-detect across the whole table
// (single rep change may flip flags on an unrelated slot — and the SMSG
// can carry multiple rep updates in one packet).
struct RepFlagsSnapshot {
uint8_t flags[Offsets::MAX_REP_SLOTS];
};
void TakeSnapshot(RepFlagsSnapshot *out) {
for (int i = 0; i < Offsets::MAX_REP_SLOTS; ++i)
out->flags[i] = ReadRepSlotFlags(i);
}
bool SnapshotsDiffer(const RepFlagsSnapshot *a, const RepFlagsSnapshot *b) {
return std::memcmp(a->flags, b->flags, sizeof(a->flags)) != 0;
}
using SmsgHandler_t = int(__stdcall *)(uint32_t opcode, void *packet);
SmsgHandler_t s_smsgSetStandings_o = nullptr;
int __stdcall SmsgSetStandings_h(uint32_t opcode, void *packet) {
RepFlagsSnapshot before;
TakeSnapshot(&before);
const int result = s_smsgSetStandings_o(opcode, packet);
RepFlagsSnapshot after;
TakeSnapshot(&after);
if (SnapshotsDiffer(&before, &after))
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetStandings{
Offsets::FUN_SMSG_SET_FACTION_STANDINGS,
reinterpret_cast<void *>(&SmsgSetStandings_h),
reinterpret_cast<void **>(&s_smsgSetStandings_o)};
// Hook 4 — SMSG_INITIALIZE_FACTIONS ──────────────────────────────────
SmsgHandler_t s_smsgInitFactions_o = nullptr;
int __stdcall SmsgInitFactions_h(uint32_t opcode, void *packet) {
const int result = s_smsgInitFactions_o(opcode, packet);
// Initial sync — engine has just populated all 64 rep slots from
// server state. Always fire so addons listening for UNIT_FACTION
// (rather than PLAYER_LOGIN / VARIABLES_LOADED) see the initial
// load. Modern WoW's behavior.
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookInitFactions{
Offsets::FUN_SMSG_INITIALIZE_FACTIONS,
reinterpret_cast<void *>(&SmsgInitFactions_h),
reinterpret_cast<void **>(&s_smsgInitFactions_o)};
// Hook 5 — SMSG_SET_FACTION_ATWAR ─────────────────────────────────────
SmsgHandler_t s_smsgSetAtWar_o = nullptr;
int __stdcall SmsgSetAtWar_h(uint32_t opcode, void *packet) {
const int result = s_smsgSetAtWar_o(opcode, packet);
// Server-pushed AT_WAR change — packet is rare and always carries
// intent to update. Match 3.3.5's behavior: fire unconditionally
// after the byte write, without delta-detecting against the slot
// (the engine writes the byte regardless of whether it differs, so
// a delta check would conflate "value didn't change" with "server
// had nothing to say" — but the server only sends this when
// something did).
FireUnitFactionPlayer();
return result;
}
const Game::HookAutoRegister _hookSetAtWar{
Offsets::FUN_SMSG_SET_FACTION_ATWAR,
reinterpret_cast<void *>(&SmsgSetAtWar_h),
reinterpret_cast<void **>(&s_smsgSetAtWar_o)};
} // namespace
} // namespace Faction::UnitFactionPolyfill
+417
View File
@@ -0,0 +1,417 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.GetItemDataByID(itemInfo)` / `C_Item.GetItemData(itemLocation)`
// — return a single table containing every field we can extract from
// the cached `ItemStats_C` record. Convenience for addons that want
// the kitchen sink without making 15 separate `C_Item.*` calls.
//
// Returns `nil` if the item isn't cached (caller should
// `RequestLoadItemData` and listen for `ITEM_DATA_LOAD_RESULT`).
// Does not warm the cache itself — this is a passive reader, same as
// `GetItemInfoInstant`'s contract.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Spell.h"
#include <cstdint>
#include <cstdio>
namespace Item::GetData {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
const uint8_t *FetchItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
int CurrentLocaleIndex() {
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
}
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
// the 9-locale name array; null/empty slots fall back to "".
const char *LookupClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
classID, Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
// (verbose name first, short name on miss).
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
if (count <= 0)
return "";
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
if (records == nullptr)
return "";
const int locale = CurrentLocaleIndex();
for (int i = 0; i < count; ++i) {
const uint8_t *record = records + i * Offsets::OFF_ITEMSUBCLASS_RECORD_STRIDE;
if (*reinterpret_cast<const uint32_t *>(record + 0x00) != classID)
continue;
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
continue;
const char *verbose = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
if (verbose != nullptr && verbose[0] != '\0')
return verbose;
const char *shortName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
}
return "";
}
const char *LookupInvType(uint32_t invType) {
if (invType > Offsets::INVTYPE_TABLE_MAX_INDEX)
return "";
auto **table = reinterpret_cast<const char **>(Offsets::VAR_INVTYPE_STRING_TABLE);
const char *s = table[invType];
return (s != nullptr && s[0] != '\0') ? s : "";
}
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
const char *iconName = DBC::StringField(
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
if (iconName == nullptr || iconName[0] == '\0')
return false;
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
return true;
}
// Wrath flipped the bagFamily encoding from raw 1-based IDs to a
// bitmask. We surface the modern shape so backported addons compile
// cleanly. See `Item::Info::BagFamilyIdToBitmask` for the full story.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// Shorthand: read a u32 field from the record at `offset`, push it on
// the stack as a Lua number, and set `t[key] = value` on the table at
// stack[-3]. `SetFieldNumber` already handles the push/set; this is
// just the field-fetch convenience.
void SetU32(void *L, const uint8_t *record, const char *key, int offset) {
const uint32_t v = *reinterpret_cast<const uint32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetI32(void *L, const uint8_t *record, const char *key, int offset) {
const int32_t v = *reinterpret_cast<const int32_t *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
void SetF32(void *L, const uint8_t *record, const char *key, int offset) {
const float v = *reinterpret_cast<const float *>(record + offset);
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
}
// Sets `t[key]` to a sub-array of N consecutive u32 values from the
// record. Pushes 1-indexed (Lua array convention).
void SetU32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const uint32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetI32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const int32_t *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
void SetF32Array(void *L, const uint8_t *record, const char *key,
int offset, int count) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
auto *arr = reinterpret_cast<const float *>(record + offset);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Sparse stats: walks the 10 (statType, statValue) slots and pushes
// only the populated ones into a map keyed by statType. Modern addons
// expect `stats[STAT_AGI] = 5`-style access; matching that here avoids
// callers having to walk the fixed-10 array themselves.
void SetStatsMap(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "stats");
Game::Lua::NewTable(L);
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
if (types[i] == 0 && values[i] == 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(types[i]));
Game::Lua::PushNumber(L, static_cast<double>(values[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Spell slots: walks all 5 slots, pushes only entries with a non-zero
// spellID as `{ id, trigger, charges, cooldown, category,
// categoryCooldown }` records. Sparse but 1-indexed in declaration
// order so the slot index is preserved.
void SetSpellsArray(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "spells");
Game::Lua::NewTable(L);
auto *ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto *charges = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
auto *cooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
auto *categories = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
int written = 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
if (ids[i] == 0)
continue;
++written;
Game::Lua::PushNumber(L, static_cast<double>(written));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
Game::Lua::SetFieldNumber(L, "trigger", static_cast<double>(triggers[i]));
Game::Lua::SetFieldNumber(L, "charges", static_cast<double>(charges[i]));
Game::Lua::SetFieldNumber(L, "cooldown", static_cast<double>(cooldowns[i]));
Game::Lua::SetFieldNumber(L, "category", static_cast<double>(categories[i]));
Game::Lua::SetFieldNumber(L, "categoryCooldown",
static_cast<double>(catCooldowns[i]));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3);
}
// Pushes a single Lua table for `record` onto the stack, populated
// with every field we can read out of `ItemStats_C`. Caller is
// responsible for verifying `record` is non-null and pushing the
// itemID as a setup step.
void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
// Identity strings
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
Game::Lua::SetFieldString(L, "name", name);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
if (description != nullptr && description[0] != '\0')
Game::Lua::SetFieldString(L, "description", description);
// Display
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
char iconPath[260];
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
Game::Lua::SetFieldString(L, "icon", iconPath);
// Quality / classification
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
// Bind
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
// Flags: raw + decoded bits.
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isConjured",
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
Game::Lua::SetFieldBool(L, "isOpenable",
(flags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0);
Game::Lua::SetFieldBool(L, "isLootable",
(flags & Offsets::ITEM_FLAG_LOOTABLE) != 0);
Game::Lua::SetFieldBool(L, "isWrapper",
(flags & Offsets::ITEM_FLAG_WRAPPER) != 0);
// Stacking / inventory
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::SetFieldNumber(L, "bagFamily",
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
// Economy
SetU32(L, record, "buyPrice", Offsets::OFF_ITEMSTATS_BUY_PRICE);
SetU32(L, record, "sellPrice", Offsets::OFF_ITEMSTATS_SELL_PRICE);
// Level / requirements
SetU32(L, record, "itemLevel", Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
SetU32(L, record, "requiredLevel", Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
SetU32(L, record, "requiredSkill", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
SetU32(L, record, "requiredSkillRank", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
SetU32(L, record, "requiredSpell", Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
SetU32(L, record, "requiredHonorRank", Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
SetU32(L, record, "requiredCityRank", Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
SetU32(L, record, "requiredFaction", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
SetU32(L, record, "requiredFactionRank", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
SetU32(L, record, "allowableClass", Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
SetU32(L, record, "allowableRace", Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
// Equipment defenses
SetU32(L, record, "armor", Offsets::OFF_ITEMSTATS_ARMOR);
SetU32(L, record, "block", Offsets::OFF_ITEMSTATS_BLOCK);
SetU32(L, record, "maxDurability", Offsets::OFF_ITEMSTATS_MAX_DURABILITY);
// Sparse stats map (statType -> statValue, only populated entries)
SetStatsMap(L, record);
// Resistances
SetU32(L, record, "resistanceHoly", Offsets::OFF_ITEMSTATS_RES_HOLY);
SetU32(L, record, "resistanceFire", Offsets::OFF_ITEMSTATS_RES_FIRE);
SetU32(L, record, "resistanceNature", Offsets::OFF_ITEMSTATS_RES_NATURE);
SetU32(L, record, "resistanceFrost", Offsets::OFF_ITEMSTATS_RES_FROST);
SetU32(L, record, "resistanceShadow", Offsets::OFF_ITEMSTATS_RES_SHADOW);
SetU32(L, record, "resistanceArcane", Offsets::OFF_ITEMSTATS_RES_ARCANE);
// Weapon
SetF32Array(L, record, "damageMin", Offsets::OFF_ITEMSTATS_DAMAGE_MIN,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetF32Array(L, record, "damageMax", Offsets::OFF_ITEMSTATS_DAMAGE_MAX,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32Array(L, record, "damageType", Offsets::OFF_ITEMSTATS_DAMAGE_TYPE,
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
SetU32(L, record, "delay", Offsets::OFF_ITEMSTATS_DELAY);
SetU32(L, record, "ammoType", Offsets::OFF_ITEMSTATS_AMMO_TYPE);
SetF32(L, record, "rangedModRange", Offsets::OFF_ITEMSTATS_RANGED_MOD_RANGE);
// Spell slots (sparse array of records) + ON_USE convenience
SetSpellsArray(L, record);
const int useSpellID = Item::Spell::OnUseSpellIDForItemID(itemID);
if (useSpellID > 0)
Game::Lua::SetFieldNumber(L, "useSpellID", static_cast<double>(useSpellID));
// Lock / item set
SetU32(L, record, "lockID", Offsets::OFF_ITEMSTATS_LOCK_ID);
SetU32(L, record, "itemSet", Offsets::OFF_ITEMSTATS_ITEM_SET);
// Books / quest / binding
SetU32(L, record, "pageText", Offsets::OFF_ITEMSTATS_PAGE_TEXT);
SetU32(L, record, "pageMaterial", Offsets::OFF_ITEMSTATS_PAGE_MATERIAL);
SetU32(L, record, "languageID", Offsets::OFF_ITEMSTATS_LANGUAGE_ID);
SetU32(L, record, "startQuest", Offsets::OFF_ITEMSTATS_START_QUEST);
SetI32(L, record, "material", Offsets::OFF_ITEMSTATS_MATERIAL);
SetU32(L, record, "sheath", Offsets::OFF_ITEMSTATS_SHEATH);
SetI32(L, record, "randomProperty", Offsets::OFF_ITEMSTATS_RANDOM_PROPERTY);
SetU32(L, record, "area", Offsets::OFF_ITEMSTATS_AREA);
SetU32(L, record, "map", Offsets::OFF_ITEMSTATS_MAP);
}
// Common path for both entry points: take an itemID, peek the cache,
// build & push the data table or push nil for uncached.
int PushDataForItemID(void *L, int itemID) {
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
PushItemDataTable(L, static_cast<uint32_t>(itemID), record);
return 1;
}
// `C_Item.GetItemDataByID(itemInfo)` — accepts numeric itemID,
// `"item:NNN..."` string, or full chat link. Returns the data table
// or nil for invalid input / uncached items.
int __fastcall Script_GetItemDataByID(void *L) {
return PushDataForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// `C_Item.GetItemData(itemLocation)` — resolves the equipment-slot /
// bag-slot location to its CGItem, extracts the itemID, then peeks
// the cache. Returns nil for empty slots, invalid locations, or
// uncached items.
int __fastcall Script_GetItemData(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemData(itemLocation)");
return 0;
}
return PushDataForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemDataByID", &Script_GetItemDataByID);
Game::Lua::RegisterTableFunction("C_Item", "GetItemData", &Script_GetItemData);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::GetData
+18
View File
@@ -254,6 +254,24 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (srcItem == nullptr)
return false;
// Vanilla server rejects `CMSG_SPLIT_ITEM` when count == srcStack
// (`EQUIP_ERR_COULDNT_SPLIT_ITEMS`) — conceptually "split N off,
// leave residual", and splitting the entire stack would leave
// source empty. The drag-and-drop UI's "drop whole stack on
// matching stack" goes through CMSG_SWAP_ITEM instead, which the
// server treats as a merge for same-item destinations (up to
// maxStack). Route there for count == srcStack so callers don't
// have to special-case "move everything".
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
const int srcStack = srcDescriptor == nullptr ? 0 :
static_cast<int>(*reinterpret_cast<const uint32_t *>(
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
if (count > srcStack)
return false; // server would reject anyway; fail fast and locally
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
if (player == nullptr)
return false;
+9 -3
View File
@@ -82,9 +82,15 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot);
// - `dst` same item & fits maxStack → merges `count` into dst
// - `dst` different item / overflow → server rejects (no partial)
//
// Source must be occupied with at least `count` items; server rejects
// if not. Returns false locally only on bad args or unresolvable bags.
// All other validation is server-side — same as `Containers` above.
// Source must be occupied with at least `count` items. Locally
// validates `count <= srcStack` (server would reject otherwise with
// `EQUIP_ERR_COULDNT_SPLIT_ITEMS` — fail fast). When `count ==
// srcStack` ("move the whole stack"), routes through `Containers`
// instead — vanilla's `CMSG_SPLIT_ITEM` rejects splits that would
// leave the source empty, but `CMSG_SWAP_ITEM` handles the case
// (merges into a matching destination stack, swaps otherwise).
// Returns false locally on bad args, unresolvable bags, or
// `count > srcStack`; all other validation is server-side.
//
// Requires Lua state for the same reason as `Containers`: the source
// CGItem lookup goes through `Item::Location::ResolveBag`. Stomps the