Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
2026-07-15 15:06:04 -05:00
21 changed files with 842 additions and 268 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [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` |
+97 -15
View File
@@ -227,11 +227,13 @@ build instructions.
- [`C_Item.GetItemUniqueness(itemLocation)` / `C_Item.GetItemUniquenessByID(item)`](#c_itemgetitemuniquenessitemlocation--c_itemgetitemuniquenessbyiditem)
- [`C_Item.GetStackCount(itemLocation)`](#c_itemgetstackcountitemlocation)
- [`C_Item.IsBound(itemLocation)`](#c_itemisbounditemlocation)
- [`IsConsumableItem(item)` / `C_Item.IsConsumableItem(item)`](#isconsumableitemitem--c_itemisconsumableitemitem)
- [`C_Item.IsEquippableItem(item)`](#c_itemisequippableitemitem)
- [`IsUsableItem(item)` / `C_Item.IsUsableItem(item)`](#isusableitemitem--c_itemisusableitemitem)
- [`C_Item.IsEquippedItem(item)`](#c_itemisequippeditemitem)
- [`C_Item.IsItemDataCachedByID(item)` / `C_Item.IsItemDataCached(itemLocation)`](#c_itemisitemdatacachedbyiditem--c_itemisitemdatacacheditemlocation)
- [`C_Item.IsItemGUIDInInventory(itemGUID)`](#c_itemisitemguidininventoryitemguid)
- [`C_Item.IsItemInRange(item, targetUnit)`](#c_itemisiteminrangeitem-targetunit)
- [`C_Item.IsItemOpenable(itemLocation)`](#c_itemisitemopenableitemlocation)
- [`C_Item.IsLocked(itemLocation)`](#c_itemislockeditemlocation)
- [`C_Item.LockItem(itemLocation)`](#c_itemlockitemitemlocation)
@@ -3476,7 +3478,7 @@ local name, link, id = GameTooltip:GetItem()
-- SetInventoryItem — full dressed link with enchant + random suffix
GameTooltip:SetInventoryItem("player", INVSLOT_BACK)
local name, link, id = GameTooltip:GetItem()
-- name = "Superior Cloak"
-- name = "Superior Cloak of the Eagle" -- decorated name, agrees with the link
-- link = "|cff1eff00|Hitem:9805:247:843:0|h[Superior Cloak of the Eagle]|h|r"
-- id = 9805
```
@@ -4430,7 +4432,9 @@ the item's inventory type.
`itemInfo` accepts the same shapes as
[`C_Item.IsEquippedItem`](#c_itemisequippeditemitem) — itemID number,
bare `"item:N"` string, full chat link, or a localized item name. Name
matches are case-insensitive against the cached `m_name[0]`.
matching is case-insensitive against each candidate's *decorated* name
(random suffix included — a suffixed item matches its full name, not the
base), the same shared predicate `C_Item.IsEquippedItem` uses.
Returns nothing. Silently no-ops when:
@@ -4963,16 +4967,27 @@ after `GET_ITEM_INFO_RECEIVED`.
### `C_Item.GetItemName(itemLocation)` / `C_Item.GetItemNameByID(item)`
Returns the cached display name as a string, or `nil` for empty /
uncached / invalid inputs. Reads `m_name[0]` straight off the
`ItemStats_C` cache record (`+0x08`) — no `GetItemInfo` round-trip.
Returns the item's display name as a string, or `nil` for empty /
uncached / invalid inputs.
Cache miss on the `ByID` form returns `nil` and fires the cache fill
so the next call (after `GET_ITEM_INFO_RECEIVED`) succeeds.
The **location** form points at a live item instance, so it returns the
*decorated* name — random suffix included (`"Iridium Chain of the Owl"`,
not the base `"Iridium Chain"`) — built off the `CGItem` via the engine's
own name builder, matching modern WoW and the bracketed name in
[`C_Item.GetItemLink`](#c_itemgetitemlinkitemlocation). (It falls back to
the base `ItemStats_C.m_name[0]` name if the instance build yields
nothing.)
The **ByID** form has no instance — an itemID can't carry a random suffix
— so it returns the base `m_name[0]` name only. Cache miss on the `ByID`
form returns `nil` and fires the cache fill so the next call (after
`GET_ITEM_INFO_RECEIVED`) succeeds.
```lua
local name = C_Item.GetItemName({bagID = 0, slotIndex = 1})
local name = C_Item.GetItemNameByID(6948) -- "Hearthstone"
-- location form → decorated name (random suffix included)
local name = C_Item.GetItemName({equipmentSlotIndex = 2}) -- "Iridium Chain of the Owl"
-- ByID form → base name only (no instance → no suffix)
local name = C_Item.GetItemNameByID(6948) -- "Hearthstone"
```
### `C_Item.GetItemQuality(itemLocation)` / `C_Item.GetItemQualityByID(item)`
@@ -5301,6 +5316,36 @@ if C_Item.IsBound({bagID = 0, slotIndex = 1}) then ... end
if C_Item.IsBound(itemGUID) then ... end
```
### `IsConsumableItem(item)` / `C_Item.IsConsumableItem(item)`
Returns `true` if the item is a consumable, `false` otherwise. An item
counts as consumable when its **class is `Consumable` (0)** *or* its
**inventory type is `INVTYPE_AMMO` (24) or `INVTYPE_THROWN` (25)** — ammo
and thrown weapons being consumed in use. Registered as both the bare
global and the namespaced `C_Item` form, like `IsUsableItem`.
`item` is an itemID number or `"item:N..."` link. Item names aren't
accepted (vanilla has no name→ID resolver). Returns `false` for uncached
items (no async load fired) — same cache contract as
`C_Item.IsEquippableItem`.
```lua
IsConsumableItem(118) -- Minor Healing Potion → true (class Consumable)
C_Item.IsConsumableItem(1251) -- Linen Bandage → true (class Consumable)
C_Item.IsConsumableItem(2512) -- Rough Arrow → true (INVTYPE_AMMO)
C_Item.IsConsumableItem(6948) -- Hearthstone → false
C_Item.IsConsumableItem(18820) -- Talisman of Ephemeral Power → false (on-use trinket)
```
This is a class/ammo check, **not** a "has a usable effect" check.
3.3.5's `IsConsumableItem` walked the item's on-use spells and returned
true for any with a real effect (which would include on-use trinkets),
but that heuristic didn't survive into the modern client: verified in-game
that an on-use trinket with a numeric `Use:` effect (Talisman of Ephemeral
Power) returns `false`, while class-`Consumable` items (potions, bandages,
the class-0 "Faintly Glowing Skull") and ammo return `true`. We match the
modern contract.
### `C_Item.IsEquippableItem(item)`
Returns `true` if `item` can be equipped in any character-pane slot,
@@ -5382,14 +5427,19 @@ short-circuits on the first match.
| itemID | `2589` | exact `itemID` equality |
| bare link | `"item:2589:0:0:0"` | parses the first numeric field |
| chat link | `\124cffffffff\124Hitem:2589:0:0:0\124h[Linen Cloth]\124h\124r` | extracts itemID after `\124Hitem:` |
| name | `"Linen Cloth"` | case-insensitive match against the cached `m_name[0]` of each equipped item |
| name | `"Linen Cloth"` | case-insensitive match against each equipped item's *decorated* name (random suffix included) |
Returns `false` (no Lua error) for invalid input — `nil`, empty string,
or a string that doesn't parse as any of the above.
The name-match path requires the candidate equipped item to be in the
client item cache; equipped items are always cached, so this is a
non-issue in practice.
Name matching is against the item's **decorated** name: a random-suffix
item matches only its full name (`"Krol Blade of the Bear"`), not the
base (`"Krol Blade"`) — the same behavior as modern WoW (verified
in-game). Unsuffixed items match their plain name. The candidate must be
in the client item cache; equipped items always are, so that's a non-issue
in practice. This match logic is shared with the by-name action APIs
(`C_Item.UseItemByName` / `EquipItemByName`) — one predicate, one set of
semantics.
```lua
if C_Item.IsEquippedItem(2589) then
@@ -5439,6 +5489,37 @@ Accepts the GUID string [`C_Item.GetItemGUID`](#c_itemgetitemguiditemlocation)
/ `UnitGUID` return; returns `false` for a malformed or zero GUID. To also
cover the bank, use `C_Item.GetItemCount(itemID, true) > 0` instead.
### `C_Item.IsItemInRange(item, targetUnit)`
Returns `true` if the item is in range of `targetUnit`, `false` if out
of range, and `nil` when the range check doesn't apply — the item has no
on-use spell, its on-use spell is rangeless, or the item / unit can't be
resolved. Matches retail, which likewise returns `nil` for items with no
range restriction.
`item` is an itemID, `"item:NNN"` string, or item link (item *names*
aren't resolvable — vanilla has no name→ID map — and return `nil`).
`targetUnit` is a unit token.
```lua
-- a targeted on-use item (net / bomb / thrown / targeted quest item):
C_Item.IsItemInRange(itemID, "target") -- true / false
-- rangeless / self-use item:
C_Item.IsItemInRange(118, "target") -- Minor Healing Potion → nil
```
An item's range comes from the spell it fires on use, so this resolves
the item to its on-use spell ([`C_Item.GetItemSpell`](#c_itemgetitemspellitem)'s
spell) and runs the exact same range test as
[`C_Spell.IsSpellInRange`](#c_spellisspellinrangespellidentifier-targetunit)
— the two agree for the same underlying spell. Range-only like the spell
version: it ignores line of sight and doesn't reject wrong-faction
targets. Passive reader — an item not yet in the client item cache
returns `nil` with no background fetch (items you'd range-check are
normally in bags / on the action bar and already cached). Absent tokens
(e.g. `"target"` with no target) return `nil`; a genuinely unrecognized
token string raises a Lua error, same contract as `C_Spell.IsSpellInRange`.
### `C_Item.IsItemOpenable(itemLocation)`
Returns two values: `(isOpenable, canOpen)`.
@@ -5707,8 +5788,9 @@ uses it. Returns nothing; silently no-ops when:
`itemInfo` accepts the same shapes as
[`C_Item.EquipItemByName`](#c_itemequipitembynameiteminfo--dstslot) —
itemID number, bare `"item:N"` string, full chat link, or a localized
item name. Name matches are case-insensitive against the cached
`m_name[0]`.
item name. Name matching is case-insensitive against each candidate's
*decorated* name (random suffix included), the same shared predicate
`C_Item.IsEquippedItem` uses.
The optional `unit` argument is a unit token (`"player"`, `"target"`,
`"focus"`, `"partyN"`, `"raidN"`, `"nameplateN"`, …) used as the cast
+32
View File
@@ -287,6 +287,18 @@ enum Offsets {
// the item isn't in the player's bag/equipment.
FUN_GAMETOOLTIP_BUILD_ITEM_LINK = 0x0052AE00,
// CGItem → decorated instance display name. __thiscall(ecx = CGItem *,
// char *outBuf, uint outSize). Reads the instance's random-suffix ID
// off the descriptor (+0x98, gated on the broken flag) and writes the
// plain — uncolored, unbracketed — display name into outBuf:
// "Ethereum Torque of the Sorcerer" when a suffix is present, the base
// ItemStats name otherwise (via the item cache + ItemRandomProperties
// localized suffix at record +0x1c). This is the inner name builder
// FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the name it
// produces matches GetItemLink's bracketed name and modern
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -1583,6 +1595,17 @@ enum Offsets {
// an item, swaps with the bag slot). Used by `EquipItemByName` to
// pick up the source item before dispatching to the equip helpers.
FUN_SCRIPT_PICKUP_CONTAINER_ITEM = 0x004F9B30,
// Container-content cursor-pickup PRIMITIVE — the plain-pickup branch
// inside `Script_PickupContainerItem`'s state machine, called directly
// (no Lua stack, no state-machine modes).
// void __fastcall(uint linearSlot, int count, uint itemGuidLo,
// uint itemGuidHi, uint containerGuidLo,
// uint containerGuidHi, int flag)
// Self-resolves the player, sets the cursor-state globals
// (VAR_CURSOR_ITEM_GUID_*, cursor type = 1) and fires the cursor-update
// event itself. Does NOT apply the cursor-empty / item-lock guards the
// Script wrapper does — callers replicate those (Item::Cursor::PickupBagItem).
FUN_CURSOR_PICKUP_ITEM = 0x00494B60,
// `CGItem::UseItem` — the engine's actual "use this item" primitive.
// It's the fallback dispatch in `Script_UseContainerItem` (the call
// site at 0x004FA430 after every special-cursor-mode branch is
@@ -4193,6 +4216,15 @@ enum Offsets {
// assemble the pickup→equip chain for each set item.
FUN_SCRIPT_CLEAR_CURSOR = 0x004895B0,
FUN_SCRIPT_PICKUP_INVENTORY_ITEM = 0x004C8DA0,
// Equipment/bag-slot pickup PRIMITIVE behind Script_PickupInventoryItem
// (`FUN_004C8DA0` is just `FUN_004C7300(arg - 1)`). Called directly:
// void __fastcall(uint slot0Based)
// Self-contained — resolves the player, applies its own cursor/lock
// guards, and handles the drop case (slot == 0xFFFFFFFF). Covers the
// 0-based equipment (0..18) and equipped-bag (19..22) slots; container
// contents go through FUN_CURSOR_PICKUP_ITEM instead. Modern
// `equipmentSlotIndex` is 1-based, so pass `equipmentSlotIndex - 1`.
FUN_PICKUP_INVENTORY_SLOT = 0x004C7300,
// Bag-update fire sites — see TODO #67 for the full Ghidra
// investigation. Hook these three to detect BAG_UPDATE fires and
+7
View File
@@ -45,6 +45,8 @@
// `CursorHasItem` / engine `PickupX` calls when they need to detect
// that.
#include "cursor/Info.h"
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
@@ -273,4 +275,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool HasItem() {
return ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
}
} // namespace Cursor::Info
+24
View File
@@ -0,0 +1,24 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Cursor::Info {
// True if a GUID-bearing item is currently on the cursor —
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
// path reads and the exact signal the engine's item-pickup path guards on.
// Used to gate cursor pickups (don't clobber a held item).
bool HasItem();
} // namespace Cursor::Info
+95
View File
@@ -0,0 +1,95 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.IsConsumableItem(item)` — true iff the item is a consumable:
// item class `Consumable` (0), or an inventory type of `INVTYPE_AMMO`
// (24) / `INVTYPE_THROWN` (25) — ammo and thrown weapons being consumed
// in use. `item` is an itemID, `"item:NNN"` string, or item link (names
// aren't resolvable in vanilla → false).
//
// Definition established by decoding 3.3.5's `Script_IsConsumableItem`
// (`0x005173E0`) and then verifying the surviving contract against a
// modern client with `/dump`: it is NOT 3.3.5's on-use-spell-effect walk
// (an on-use trinket like Talisman of Ephemeral Power returns *false*),
// but a plain class-or-ammo/thrown check. Confirmed true: Minor Healing
// Potion / Linen Bandage / Faintly Glowing Skull (all class 0), Rough
// Arrow (Ammo). Confirmed false: Hearthstone (class Misc, valueless
// teleport), on-use trinkets, plain armor.
//
// Cache-miss handling mirrors `C_Item.IsEquippableItem`: returns false
// without firing a load (sync call, matches modern semantics). Callers
// that want auto-warm behavior should run `C_Item.RequestLoadItemDataByID`
// first and re-check on `ITEM_DATA_LOAD_RESULT`.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include <cstdint>
namespace Item::Consumable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
constexpr uint32_t kItemClassConsumable = 0;
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
int __fastcall Script_C_Item_IsConsumableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const bool consumable = classID == kItemClassConsumable ||
invType == kInvTypeAmmo || invType == kInvTypeThrown;
Game::Lua::PushBoolean(L, consumable);
return 1;
}
void RegisterLuaFunctions() {
// Modern exposes both the namespaced `C_Item.IsConsumableItem` and the
// bare global `IsConsumableItem` (the older, still-live form); register
// both against the same handler, like `IsUsableItem`.
Game::Lua::RegisterTableFunction("C_Item", "IsConsumableItem",
&Script_C_Item_IsConsumableItem);
Game::Lua::RegisterGlobalFunction("IsConsumableItem",
&Script_C_Item_IsConsumableItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Consumable
+91
View File
@@ -0,0 +1,91 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/Cursor.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Cursor {
namespace {
using PickupInventorySlot_t = void(__fastcall *)(uint32_t slot0Based);
using CursorPickupItem_t = void(__fastcall *)(uint32_t linearSlot, int count,
uint32_t itemGuidLo, uint32_t itemGuidHi,
uint32_t containerGuidLo,
uint32_t containerGuidHi, int flag);
using LockByGuid_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi);
// The container object stores its own GUID inline at +0x08/+0x0C (unlike a
// CGItem, whose GUID sits behind the +0x08 instance-block pointer). This
// is the "source container" the engine records for the eventual drop.
constexpr int OFF_CONTAINER_GUID_LO = 0x08;
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
bool ItemLocked(const uint8_t *item) {
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
}
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
// block pointer is absent.
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
} // namespace
void PickupEquipmentSlot(int equipmentSlotIndex) {
// The primitive takes a 0-based slot; modern equipmentSlotIndex is 1-based.
reinterpret_cast<PickupInventorySlot_t>(Offsets::FUN_PICKUP_INVENTORY_SLOT)(
static_cast<uint32_t>(equipmentSlotIndex - 1));
}
bool PickupBagItem(void *L, int bagID, int slotIndex) {
Item::Location::BagSlotDetail d;
if (!Item::Location::ResolveBagSlotDetail(L, bagID, slotIndex, &d) || d.item == nullptr)
return false;
// Guards the Script_ wrapper applies that the raw primitive omits: only
// pick up onto an empty cursor, and never a locked (mid-transaction) item.
// `::Cursor` — unqualified `Cursor` would bind to `Item::Cursor` here.
if (::Cursor::Info::HasItem() || ItemLocked(d.item))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadItemGuid(d.item, &itemLo, &itemHi))
return false;
const uint32_t containerLo =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_LO);
const uint32_t containerHi =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_HI);
reinterpret_cast<CursorPickupItem_t>(Offsets::FUN_CURSOR_PICKUP_ITEM)(
static_cast<uint32_t>(d.linearSlot), 1, itemLo, itemHi, containerLo, containerHi, 0);
// Visually lock the source item while it's held — the Script pickup
// does this immediately after the cursor primitive.
reinterpret_cast<LockByGuid_t>(Offsets::FUN_ITEM_LOCK_BY_GUID)(itemLo, itemHi);
return true;
}
} // namespace Item::Cursor
+35
View File
@@ -0,0 +1,35 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Cursor pickup primitives — thin C++ wrappers over the engine's own
// pickup functions (`FUN_PICKUP_INVENTORY_SLOT` / `FUN_CURSOR_PICKUP_ITEM`),
// called directly rather than through the Lua `Script_Pickup*` state-machine
// wrappers (which would require faking the Lua stack). Shared by
// `C_Item.PickupItem` and `C_Item.EquipItemByName`'s auto-slot path.
namespace Item::Cursor {
// Picks the item in a 1-based character-pane equipment slot onto the
// cursor via the engine's self-contained equipment-pickup primitive
// (which resolves the player and applies its own cursor/lock guards).
void PickupEquipmentSlot(int equipmentSlotIndex);
// Picks the item in a bag slot onto the cursor. Guarded to match the Lua
// wrapper's behavior: no-op (returns false) when the slot is empty, the
// cursor already holds an item, or the item is locked (mid-transaction).
// Returns true when the pickup was issued. Needs `L` for slot resolution
// (PackBagSlot); stomps the Lua stack.
bool PickupBagItem(void *L, int bagID, int slotIndex);
} // namespace Item::Cursor
+15 -36
View File
@@ -14,12 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
#include <string.h>
namespace Item::Equipment {
@@ -132,31 +132,13 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Equipment-first walk; itemID or decorated-name match via the shared
// `Item::Location::MatchesArg` predicate (same logic the bag find-by-
// name uses, so match semantics live in one place).
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = Item::Location::ResolveEquipmentSlot(slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
continue;
}
// Name-match path — peek the item-cache record and compare
// `m_name[0]`. Uncached items short-circuit to "no match"
// rather than firing a load; matches modern API semantics
// (sync call, possibly stale, never blocks).
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
if (Item::Location::MatchesArg(item, arg)) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
@@ -200,8 +182,6 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
const bool hasDstSlot = Game::Lua::IsNumber(L, 2);
const int dstSlot = hasDstSlot ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
using ScriptFn_t = int(__fastcall *)(void *L);
// Return any held cursor item to its slot BEFORE searching for
// the target. FUN_INVENTORY_SWAP's end-of-call cleanup at
// FUN_00495190(0, 1) clears LOCAL cursor globals but skips the
@@ -222,17 +202,16 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
return 0;
}
// Auto-slot path: pickup the item via Script_PickupContainerItem
// (its state-machine handles spell-cast targeting / repair /
// enchant-scroll / etc., none of which we want to replicate), then
// call the engine's `CGPlayer::AutoEquipCursorItem` helper directly
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
// Auto-slot path: pick the item up onto the cursor, then call the
// engine's `CGPlayer::AutoEquipCursorItem` helper directly
// (`Script_AutoEquipCursorItem` is a thin wrapper that just resolves
// the local player and calls this with flag=0). The pickup goes through
// the shared `Item::Cursor::PickupBagItem`, which drives the engine's
// cursor primitive directly (no Script_PickupContainerItem stack-fake);
// its empty-cursor guard is satisfied by the ClearCursor above. Bail if
// the pickup didn't take (locked item / busy cursor) — nothing to equip.
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
+74
View File
@@ -0,0 +1,74 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.IsItemInRange(item, targetUnit)` — true if the item is in
// range of the unit, false if out of range, nil when the range check
// doesn't apply. `item` is an itemID, `"item:NNN"` string, or item link
// (names aren't resolvable — vanilla has no name→ID map — and yield nil);
// `targetUnit` is a unit token ("target", "mouseover", "party1", …).
//
// An item's range comes from the spell it fires on use: we resolve the
// item to its on-use spell (`Item::Spell::OnUseSpellIDForItemID`) and run
// the same range test as `C_Spell.IsSpellInRange`, via the shared
// `Spell::Range::PlayerVsUnit` core. So items with a targeted on-use
// effect that has a range (nets, bombs, thrown/targeted quest items, …)
// get a true/false answer; items with no on-use spell, or whose on-use
// spell is rangeless (potions, food, self-buff trinkets, hearthstone),
// return nil — matching retail, which also reports nil for items without
// a range restriction.
//
// Range-only, like `IsSpellInRange`: ignores line of sight and target
// faction. Passive reader — an item not yet in the client item cache
// returns nil (no background fetch); items you'd range-check are normally
// in bags / on the action bar and already cached.
#include "Game.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Range.h"
#include <cstdint>
namespace Item::InRange {
namespace {
int __fastcall Script_C_Item_IsItemInRange(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const int spellID =
itemID > 0 ? Item::Spell::OnUseSpellIDForItemID(
static_cast<uint32_t>(itemID))
: 0;
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// `::Spell` — unqualified `Spell` would bind to `Item::Spell` here.
const int result = ::Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemInRange",
&Script_C_Item_IsItemInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::InRange
+13
View File
@@ -27,6 +27,9 @@ namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -50,6 +53,16 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
if (cgItem == nullptr || out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildInstanceName_t>(
Offsets::FUN_ITEM_BUILD_INSTANCE_NAME);
fn(cgItem, out, static_cast<unsigned>(outSize));
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
+11
View File
@@ -29,6 +29,17 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Writes the item's per-instance decorated *display name* into `out` —
// the same `[Name]` the engine stamps into `FromCGItem`'s link, e.g.
// "Iridium Chain of the Owl" (or the base name when the instance carries
// no random suffix). Reads the suffix ID off the CGItem descriptor
// (`+0x98`) and formats it through the engine's own name builder
// (`FUN_ITEM_BUILD_INSTANCE_NAME`). Returns false (and leaves `out`
// empty) for a null item or an empty build result, so callers can fall
// back to the base ItemStats name. Use whenever a name is returned
// alongside a `FromCGItem` link, so the two agree.
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
+72 -64
View File
@@ -18,6 +18,7 @@
#include "../guid/Guid.h"
#include "Arg.h"
#include "ID.h"
#include "Link.h"
#include <cstring>
@@ -56,24 +57,10 @@ bool TryReadIntField(void *L, int locIdx, const char *fieldName, int *out) {
}
const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *invMgr = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &invMgr, &linearSlot, &unused) || invMgr == nullptr)
BagSlotDetail d;
if (!ResolveBagSlotDetail(L, bagID, slotIndex, &d))
return nullptr;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, linearSlot));
return d.item;
}
// --- GUID-walk helpers ---------------------------------------------------
@@ -129,6 +116,30 @@ int GetBagSlotCount(int bagID) {
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
}
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *container = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &container, &linearSlot, &unused) || container == nullptr)
return false;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
out->container = static_cast<const uint8_t *>(container);
out->linearSlot = linearSlot;
out->item = static_cast<const uint8_t *>(GetItemBySlot(container, linearSlot));
return true;
}
const uint8_t *ResolveBag(void *L, int bagID, int slotIndex) {
return ResolveBagSlot(L, bagID, slotIndex);
}
@@ -152,38 +163,29 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
Item::Arg::Resolved arg{};
arg.itemID = itemID;
arg.name = nullptr;
return FindByArg(L, arg, out);
}
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
return false;
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg) {
const int id = Item::ID::FromCGItem(cgItem);
if (id == 0)
return false;
if (arg.itemID > 0)
return id == arg.itemID;
if (arg.name == nullptr)
return false;
// Compare against the item's *decorated* display name (random suffix
// included) — modern IsEquippedItem / find-by-name match the full name
// only ("Foo of the Owl", not the base "Foo"; verified on the modern
// client). NameFromCGItem falls back to the base name for unsuffixed
// items, so plain items still match; uncached/unnameable → no match.
char name[128];
if (!Item::Link::NameFromCGItem(cgItem, name, sizeof(name)))
return false;
return _stricmp(name, arg.name) == 0;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
@@ -196,24 +198,9 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
if (!MatchesArg(item, arg))
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
@@ -224,6 +211,27 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
return false;
}
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
// Equipment slots first (no Lua stack interaction, cheaper), matching
// `FindByItemID`'s equipment-before-bags order; then fall through to the
// bag walk. Both compare with the shared `MatchesArg` predicate.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item != nullptr && MatchesArg(item, arg)) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
return FindByArgInBags(L, arg, out);
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+36 -3
View File
@@ -96,6 +96,22 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
// Full detail for a bag slot, for callers that drive the engine's
// cursor-pickup primitive — which needs the *container object* and the
// engine *linear slot*, not just the CGItem.
struct BagSlotDetail {
const uint8_t *container; // PackBagSlot container (invMgr for the backpack, else the bag)
int linearSlot; // engine linear inventory slot
const uint8_t *item; // CGItem at the slot, or null if the slot is empty
};
// Resolves `(bagID, slotIndex)` to its `BagSlotDetail`. Returns false when
// the coordinates don't resolve (bad bag / out-of-range slot); a resolved
// but empty slot returns true with `out->item == nullptr`. Stomps the Lua
// stack (PackBagSlot). `ResolveBag`/`ResolveBagSlot` are thin wrappers that
// keep only `out->item`.
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
@@ -103,10 +119,19 @@ namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// The single item name/id match predicate: true if `cgItem` satisfies
// the resolved arg. `arg.itemID > 0` ⇒ exact itemID compare; otherwise
// `arg.name` is matched case-insensitively against the item's *decorated*
// display name (random suffix included — via `Item::Link::NameFromCGItem`,
// which falls back to the base name for unsuffixed items). Matches modern
// `IsEquippedItem` / by-name semantics: "Foo of the Owl" matches, the base
// "Foo" does not. Null / uncached / unnameable items → false. Shared by
// `FindByArgInBags` and `C_Item.IsEquippedItem` so the match logic lives
// in exactly one place.
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg);
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// first item matching `arg` (via `MatchesArg`). On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
@@ -116,6 +141,14 @@ namespace Item::Location {
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Like `FindByArgInBags` but searches **equipment (1..19) first, then
// bags** — for callers that act on an item wherever it's carried (e.g.
// `C_Item.PickupItem`). On hit `out->equipmentSlotIndex` is non-zero for
// an equipped match, else `out->bagID`/`out->slotIndex` for a bag match.
// `FindByItemID` is the itemID-only specialization of this. Stomps the
// Lua stack.
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+25 -5
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -56,18 +57,37 @@ int PushNameForItemID(void *L, int itemID) {
return 1;
}
// Pushes the suffix-decorated display name for a resolved `CGItem` via
// the shared engine name builder. Returns 0 (pushes nothing) for a null
// item or an empty build result, so the caller can fall back to the
// base-name-by-itemID path.
int PushInstanceName(void *L, const uint8_t *cgItem) {
char buf[128];
if (!Item::Link::NameFromCGItem(cgItem, buf, sizeof(buf)))
return 0;
Game::Lua::PushString(L, buf);
return 1;
}
} // namespace
// `C_Item.GetItemName(itemLocation)` — returns the cached display name
// of the item at the location, or nil for empty / uncached / invalid
// inputs. Reads `m_name[0]` straight off `ItemStats_C` — no engine
// round-trip, no `GetItemInfo` chaining.
// `C_Item.GetItemName(itemLocation)` — returns the display name of the
// item at the location, or nil for empty / uncached / invalid inputs.
// The location points at a real item instance, so the name carries any
// random-suffix decoration ("... of the Sorcerer") — built off the
// CGItem via the engine's own name builder, matching modern WoW and
// `C_Item.GetItemLink`'s bracketed name. Falls back to the base
// `ItemStats_C` name (and warms the cache) if the instance build yields
// nothing.
static int __fastcall Script_C_Item_GetItemName(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemName(itemLocation)");
return 0;
}
return PushNameForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
const uint8_t *cgItem = Item::Location::Resolve(L, 1);
if (int pushed = PushInstanceName(L, cgItem))
return pushed;
return PushNameForItemID(L, Item::ID::FromCGItem(cgItem));
}
// `C_Item.GetItemNameByID(itemInfo)` — returns the cached display name
+63
View File
@@ -0,0 +1,63 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.PickupItem(itemInfo)` — finds the item matching `itemInfo` in
// the player's equipment or bags and picks it up onto the cursor.
// `itemInfo` is an itemID, `"item:NNN"` string, item link, or item name —
// the same input `C_Item.UseItemByName` / `EquipItemByName` accept,
// resolved via `Item::Arg::Resolve` and matched by `FindByArg` (itemID, or
// decorated name for a name arg) across equipment (1..19, checked first)
// and bags (0..4).
//
// The pickup goes through the shared `Item::Cursor` wrappers, which call
// the engine's cursor primitives directly (no Lua `Script_Pickup*`
// wrapper): an equipped match uses the equipment-slot primitive, a bag
// match the bag primitive (guarded on an empty cursor + unlocked item).
// Silently no-ops on bad input, no match, a busy cursor, or a locked item.
#include "Game.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/Location.h"
namespace Item::Pickup {
namespace {
int __fastcall Script_C_Item_PickupItem(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr)
return 0;
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArg(L, arg, &found))
return 0;
// equipmentSlotIndex != 0 → equipped (paperdoll slot); else a bag slot.
if (found.equipmentSlotIndex != 0)
Item::Cursor::PickupEquipmentSlot(found.equipmentSlotIndex);
else
Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "PickupItem",
&Script_C_Item_PickupItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Pickup
+13 -18
View File
@@ -220,24 +220,19 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const char *link = Item::Link::FromCGItem(
static_cast<const uint8_t *>(cgItem));
if (link != nullptr && *link != '\0') {
// Engine's link builder also writes the dressed
// (random-suffixed) name into the link's `[Name]`
// slot; pull it out by reading the cached base name
// for the return value. The engine doesn't expose
// the dressed name as a separate string, so we
// return the base name — matches modern semantics
// where (name, link) name is the cached display name
// and the link is the full hyperlink.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record != nullptr) {
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name != nullptr && *name != '\0') {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
// Return the dressed (random-suffixed) name the engine
// wrote into the link's `[Name]` slot — built off the same
// CGItem via Item::Link::NameFromCGItem — so the returned
// name agrees with the link ("Iridium Chain of the Owl",
// not the base "Iridium Chain") and matches modern
// GameTooltip:GetItem() on suffixed items.
char name[128];
if (Item::Link::NameFromCGItem(
static_cast<const uint8_t *>(cgItem), name, sizeof(name))) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
}
}
+3 -43
View File
@@ -23,57 +23,17 @@
#include "Arg.h"
#include "Lookup.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstring>
namespace Spell::HasRange {
namespace {
// Spell.dbc RangeIndex field — mirrors `Script_GetSpellInfo`'s
// local constant. Future refactor: promote to `Offsets.h`.
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
// SpellRange.dbc record layout (verified, documented in CLAUDE.md
// under "Sub-DBC record layouts"):
// +0x00 id, +0x04 minRange (float), +0x08 maxRange (float), ...
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *spellRec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (spellRec == nullptr)
return false;
const uint32_t rangeIdx = *reinterpret_cast<const uint32_t *>(
spellRec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT,
rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
} // namespace
static int __fastcall Script_C_Spell_SpellHasRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
@@ -94,7 +54,7 @@ static int __fastcall Script_SpellHasRange(void *L) {
bookType = 1;
}
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
+8 -83
View File
@@ -18,106 +18,31 @@
// name of a spell in the player's spellbook; `targetUnit` is a unit
// token ("target", "mouseover", "party1", …).
//
// Backed by the engine's own geometric range core `FUN_006E47B0` — the
// same test the action-button range coloring uses:
// Script_IsActionInRange (0x004E7550) → FUN_004E56F0 → FUN_006E4440
// → FUN_006E47B0
// It derives the spell's min/max range (via FUN_006E3480, which folds
// in the target's bounding radius), then compares center-to-center
// distance, so the boundary matches the client's own "in range"
// exactly — melee ranges and min-range (too-close) spells included.
//
// We call the pure core, not the higher-level FUN_006E4440: that
// wrapper also gates on target *type* (friend/foe/etc.) and collapses
// "range doesn't apply" into the same truthy return as "in range", so
// it can't yield a clean true/false/nil. The core answers range only,
// matching retail IsSpellInRange's range-only semantics (it ignores
// line of sight). One deliberate consequence: we don't reject
// wrong-faction targets — a friendly-only heal still reports a range
// answer against an enemy. Callers needing target validity should
// check it separately.
// Thin Lua wrapper over the shared `Spell::Range::PlayerVsUnit` core
// (see spell/Range.h) — the same range test behind `C_Item.IsItemInRange`
// (via the item's on-use spell). Range-only, ignoring line of sight and
// target faction, matching retail IsSpellInRange.
#include "Arg.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "unit/Position.h"
#include <cstdint>
namespace Spell::InRange {
namespace {
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets — mirrors
// `Spell::HasRange` (kept local to avoid a cross-TU dependency; small
// and stable, same layout documented in CLAUDE.md).
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
// True iff the spell's SpellRange.dbc row has a non-zero min or max.
// Rangeless spells (self buffs, etc.) have no meaningful "in range"
// answer, so we return nil for them — matching retail.
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
int spellID, char *outFlag);
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// nil when the range check can't apply: unknown/rangeless spell, or
// no unit token given.
if (spellID <= 0 || unit == nullptr || !SpellIDHasRange(spellID)) {
const int result = Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
void *caster = Unit::Position::ResolveToken("player");
void *target = Unit::Position::ResolveToken(unit);
// Guard positions before handing the objects to the engine core:
// FUN_006E47B0 dereferences each GetPosition result without a null
// check, and a resolved-but-position-less object (or an absent token
// → null) would otherwise crash. A failed read → nil.
float casterPos[3];
float targetPos[3];
if (!Unit::Position::Read(caster, casterPos) ||
!Unit::Position::Read(target, targetPos)) {
Game::Lua::PushNil(L);
return 1;
}
char minMaxFlag = 0;
auto check =
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
const char inRange = check(caster, target, spellID, &minMaxFlag);
Game::Lua::PushBool(L, inRange != 0);
Game::Lua::PushBool(L, result == 1);
return 1;
}
+84
View File
@@ -0,0 +1,84 @@
// 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 "Range.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "unit/Position.h"
namespace Spell::Range {
namespace {
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets (verified,
// documented in CLAUDE.md under "Sub-DBC record layouts").
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
// FUN_SPELL_RANGE_CHECK: char __fastcall(caster, target, spellID, outFlag).
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
int spellID, char *outFlag);
} // namespace
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
int PlayerVsUnit(int spellID, const char *unitToken) {
// No answer when the range check can't apply: unknown/rangeless spell,
// or no unit token given.
if (spellID <= 0 || unitToken == nullptr || !SpellIDHasRange(spellID))
return -1;
void *caster = Unit::Position::ResolveToken("player");
void *target = Unit::Position::ResolveToken(unitToken);
// Guard positions before handing the objects to the engine core:
// FUN_SPELL_RANGE_CHECK dereferences each GetPosition result without a
// null check, and a resolved-but-position-less object (or an absent
// token → null) would otherwise crash. A failed read → no answer.
float casterPos[3];
float targetPos[3];
if (!Unit::Position::Read(caster, casterPos) ||
!Unit::Position::Read(target, targetPos))
return -1;
char minMaxFlag = 0;
auto check =
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
return check(caster, target, spellID, &minMaxFlag) != 0 ? 1 : 0;
}
} // namespace Spell::Range
+43
View File
@@ -0,0 +1,43 @@
// 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 spell-range core. One place for "does this spell have a range"
// and "is the player's cast of this spell in range of a unit", used by
// `C_Spell.SpellHasRange`, `C_Spell.IsSpellInRange`, and (via the item's
// on-use spell) `C_Item.IsItemInRange`.
namespace Spell::Range {
// True iff the spell's `SpellRange.dbc` row has a non-zero min or max
// range. Rangeless spells (self buffs, etc.) have no meaningful "in
// range" answer.
bool SpellIDHasRange(int spellID);
// Range check of the local player's cast of `spellID` against
// `unitToken` (`"target"`, `"mouseover"`, `"party1"`, …). Returns:
// 1 — in range
// 0 — out of range
// -1 — no answer: `spellID <= 0`, a rangeless spell, a null/absent
// unit token, or either unit's position can't be read.
// Backed by the engine's geometric range core `FUN_SPELL_RANGE_CHECK`
// (the same test the action-button range coloring uses), which folds in
// the target's bounding radius so the boundary matches the client's own
// "in range" exactly — melee and min-range (too-close) spells included.
// Ignores line of sight and target faction, matching retail's range-only
// `Is*InRange` semantics.
int PlayerVsUnit(int spellID, const char *unitToken);
} // namespace Spell::Range