Compare commits

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

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

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

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

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

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

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

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

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

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

Adds Offsets::FUN_SPELL_RANGE_CHECK (0x006E47B0) with derivation notes,
plus API.md and README entries.
2026-07-14 15:00:28 -05:00
25 changed files with 1424 additions and 317 deletions
+3 -3
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` |
@@ -54,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -62,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
+200
View File
@@ -3615,3 +3615,203 @@ vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
lines inherit height/spacing identical to the template's 1..30 so the
tooltip grows seamlessly. Both are observable on the first >30-line
hover.
## 98. `UnitThreatSituation` / `UnitDetailedThreatSituation` / `GetThreatStatusColor` — NOT FEASIBLE in a client DLL
Modern (WotLK+) exposes three threat APIs:
[`UnitThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitThreatSituation)
(unit → 0..3 aggro status),
[`UnitDetailedThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitDetailedThreatSituation)
(isTanking, status, threatPct, rawThreatPct, threatValue), and
[`GetThreatStatusColor`](https://warcraft.wiki.gg/wiki/API_GetThreatStatusColor)
(status → RGB). None of them can be backed by ClassicAPI, because
**the underlying data does not exist on the 1.12 client** — not in a
form a DLL can read, and not by any amount of RE.
### Threat is 100% server-authoritative in vanilla; no threat opcode exists
Vanilla 1.12's threat lives entirely inside the server's
`ThreatManager` (per-creature `ThreatList` of `HostileReference`s —
see Turtle's
[`src/game/Threat/ThreatManager.cpp`](C:\Git\turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Threat\ThreatManager.cpp)).
The 3.3.5 client can show a threat meter because WotLK added
`SMSG_THREAT_UPDATE` / `SMSG_HIGHEST_THREAT_UPDATE` (and the client
keeps a per-unit threat table those packets feed). **1.12 has no such
opcode** — the protocol never transmits per-unit threat values, so
there is nothing in client memory to read. This is the same class of
limitation as `C_QuestLog.IsQuestFlaggedCompleted` (TODO #1): the
client simply isn't told. Unlike cast timing (TODO #91, recoverable
from `SMSG_SPELL_START`) or aura casters (recoverable from
`SMSG_SPELL_GO`), there is **no incidental packet** that leaks threat
either — the server computes tanking/aggro decisions internally and
broadcasts none of the numbers.
### How Turtle actually does it — a *server-side* addon-channel protocol
TWThreat (`C:\WoW\Octo\Interface\Addons\TWThreat`) is not reading
client memory; it round-trips through a **custom server patch**:
1. Client → server: the addon `SendAddonMessage("TWT_UDTSv4"[.._TM],
"limit=NN", "PARTY"/"RAID")` — a normal `CMSG_MESSAGECHAT` addon
message (`TWThreat.lua:1207` `TWT.UnitDetailedThreatSituation`).
2. Server intercepts the prefix in
[`ChatHandler.cpp:1051`](turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Handlers\ChatHandler.cpp)
(`strstr(msg, "TWT_UDTSv4")`), validates
`GetSelectedCreature()->CanHaveThreatList()`, and calls
`ThreatManager::UnitDetailedThreatSituation(creature, player, limit,
tankMode)`.
3. That serializer (`ThreatManager.cpp:441`) walks the real
`getThreatList()` and builds a `TWTv4=name:pct:threat;…` string,
then `SendAddonMessage`s it back.
4. Client parses it in `CHAT_MSG_ADDON` → `TWT.handleThreatPacket`
(`TWThreat.lua:790`).
Every threat number the meter shows originated on the server and was
put on the wire by a server-side code change ClassicAPI has no
counterpart to. A client DLL cannot manufacture data the server never
sends.
### Even where a server *does* expose it, the transport is async
The Turtle path is a request/response over the addon chat channel —
fundamentally asynchronous (poll on `OnUpdate`, handle the reply on a
later frame). It cannot back a **synchronous** `UnitThreatSituation()`
that returns a value in the same call, which is exactly why TWThreat is
structured around events and a cached `TWT.threats` table rather than a
blocking query. A ClassicAPI shim would at best have to fake a
synchronous return from a stale cache, and the cache only exists if a
Turtle-style server is feeding it — i.e. the shim would add nothing the
addon isn't already doing in Lua.
### `GetThreatStatusColor` is the only client-pure piece — and it's trivial Lua
`GetThreatStatusColor(status)` is just a fixed 0..3 → RGB map (grey /
yellow / orange / red); no engine data involved. But it's meaningless
without a real `status` to feed it, and it's a two-line Lua table
lookup — no reason to spend a DLL global on it. If we ever ship a
threat polyfill it belongs beside the (Lua, server-dependent) status
source, not in the DLL.
**Verdict:** skip all three. The data is server-only with no vanilla
opcode, the only working implementation (Turtle) is a server patch plus
an async addon-channel protocol that lives correctly in Lua, and the
one client-pure function is a trivial color table. Nothing here is a
client-DLL problem.
### If we synthesized it anyway
"Can't read it" and "can't estimate it" are separate claims. We can't
read threat (above); *estimating* it is possible, but it splits into two
tiers, and neither one is a DLL feature.
**Tier 1 — client-only proxy from the mob's target (small, honest,
nearly useless).** The one threat-correlated fact vanilla *does* sync to
the client is the creature's current target (`UNIT_FIELD_TARGET` in the
descriptor — the same field `"<mob>target"` / `targettarget` reads). A
mob attacks its highest-threat valid target, so `mobTarget == unit` ≈
"unit is tanking". That yields:
- `UnitThreatSituation(unit, mob)` → `3` when the mob targets `unit`,
else `nil`. It **cannot** produce `1` ("higher than tank, about to
pull") or distinguish `2` from `3` — those are numeric comparisons and
we have no numbers.
- `UnitDetailedThreatSituation` → `isTanking` only; `status` degenerate,
`threatpct` / `rawthreatpct` / `threatvalue` all unavailable.
- Laggy: it changes only when the mob switches target (a descriptor
update), so it *trails* real threat and gives zero predictive warning
— the entire reason a threat meter exists.
This needs no DLL: `UnitIsUnit("<mob>target", unit)` already does it in
pure Lua. Shipping it under the real API names would be actively
misleading (callers expect the 0–3 gradient plus percentages), so it's
not worth it even as a stub.
**Tier 2 — a real threat model (accurate-ish, huge, cooperative, ~95%
Lua).** This is what KTM / KLHThreatMeter / vanilla-Omen actually do, and
the only synthesis that fills the percentage/prediction dimensions the
API is *for*:
1. **Model your own threat.** Parse your outgoing combat events and
convert them to threat via a per-spell coefficient database (damage ≈
1 threat per point, healing ≈ 0.5/HP applied to *every* mob in combat,
flat-threat abilities like Sunder Armor / Demo Shout, per-spell
bonus/penalty coefficients), then apply your active multipliers —
stance/form (Defensive 1.3×, Berserker 0.8×, Bear/Cat), Righteous Fury
(+Holy threat), Blessing of Salvation (0.7×), threat-reduction
trinkets — and handle the sets/wipes: taunt (sets you to current top),
Feign Death, Vanish, Fade, Cower.
2. **Broadcast** your per-mob estimate over `SendAddonMessage` and
**aggregate** everyone else's broadcasts into a per-mob table.
3. Derive `isTanking` / `status` / the percentages by comparing each
member to the current tank against the **110% melee / 130% ranged**
pull threshold — the exact constants both Turtle's server serializer
(`ThreatManager.cpp:494`, `threatValue*100 / (tankThreat * (isMelee ?
1.1 : 1.3))`) and TWThreat's client fallback (`TWThreat.lua:932`)
use. Worth stealing verbatim so a synthesized `threatpct` lines up
with what real-threat clients show.
Why it isn't ours to build:
- The coefficient DB is **game-knowledge, not in the binary** — you
cannot RE it out of the client; it's the classic hand-maintained
Threat-1.0/KTM data. The parsing, broadcast, and aggregation are all
Lua. The talent/aura/stance *inputs* are already exposed by existing
ClassicAPI functions (`GetTalentInfo`, the aura APIs, `IsPlayerSpell`,
the spell-mod reads). **None of it is a client-DLL problem.**
- **Cooperative-only.** You can precisely model only members running the
same addon and broadcasting; a non-participating tank is invisible (the
original KTM caveat). The intuitive objection — "I can see everyone's
damage in the combat log, why can't I model them from one client?" —
fails for three compounding reasons, none liftable by the DLL:
1. **You don't actually see everyone's events.** Vanilla's combat log
is visibility/range-gated — the server only broadcasts combat events
for units near you, not the whole raid. A stacked 5-man logs most of
it (so a single-observer model degrades *gracefully* in dungeons); a
spread 40-man leaves you blind to the ranged camp, the back-line
healers, anyone outside your bubble. The DLL can't recover these —
the server never sends the packets, so there's nothing to hook.
2. **Threat ≠ damage, and the missing factor is hidden.** Threat is
`damage × stance × talents × buffs`, not 1:1. The talent factor
(Defiance, Subtlety, Feral Instinct, Shadow Affinity, Frost
Channeling — the passive threat-mod talents) is **unreadable for
other players in vanilla**; there's no inspect path to it. Stance /
Blessing of Salvation / Fade are at least observable auras when the
unit is in range, but talents are pure hidden state, so an observer
can't derive a stranger's coefficient from their damage.
3. **Much threat has no attributable log line.** In-combat healing
generates threat on *every* mob the healer is engaged with (not the
mob the heal touched); buffs / HoTs / PW:Fortitude, flat-threat
abilities, and pet threat either don't appear in the log or can't be
pinned to the right mob from a single line.
Broadcasting sidesteps all three because each client computes threat
from *private, authoritative* knowledge — its own exact casts, its own
talents, its own active stance/buffs — then shares the finished number.
The Tier-1 mob-target read is worth keeping here *only* as a cross-check
— it reveals who actually holds aggro regardless of who's broadcasting.
- **It's an estimate that drifts.** Addon threat models desync from the
server's true numbers (rounding, unmodeled effects, out-of-range or
missed events). That drift is precisely why Blizzard later added
`SMSG_THREAT_UPDATE` and why Turtle chose a server serializer over
blessing an addon guess.
**The only DLL-shaped angle — and it's an *input*, not threat.** The one
place a DLL beats Lua is the feed: vanilla's combat log is localized text
keyed by unit *name* (ambiguous with duplicate names, breaks per-locale).
Hooking the damage/heal packet handlers (`SMSG_SPELLNONMELEEDAMAGELOG`,
`SMSG_ATTACKERSTATEUPDATE`, `SMSG_PERIODICAURALOG`) to emit precise,
GUID-keyed, unlocalized damage/heal events would make any Lua threat
model far more robust. But (a) that's a **separate feature** — a
`COMBAT_LOG_EVENT`-style feed, useful well beyond threat — that should be
filed on its own merits; (b) it still only *feeds* the Lua model — threat
stays an estimate, computed cooperatively in Lua; and (c) on the actual
target environment SuperWoW already exposes GUID-keyed combat info +
`UNIT_CASTEVENT`, so even this niche is largely pre-filled.
**Bottom line on synthesis:** Tier 1 is a Lua one-liner too degenerate to
wear the API's name; Tier 2 is a full KTM/Omen reimplementation that is
almost entirely Lua data + logic, cooperative-only, and inherently an
estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
+159 -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)
@@ -349,6 +351,7 @@ build instructions.
- [`C_Spell.IsCurrentSpell(spellIdentifier)`](#c_spelliscurrentspellspellidentifier)
- [`C_Spell.IsSelfBuff(spellID)`](#c_spellisselfbuffspellid)
- [`C_Spell.SpellHasRange(spellIdentifier)` / `SpellHasRange(slot, bookType)`](#c_spellspellhasrangespellidentifier--spellhasrangeslot-booktype)
- [`C_Spell.IsSpellInRange(spellIdentifier, targetUnit)`](#c_spellisspellinrangespellidentifier-targetunit)
- [`C_Spell.IsAutoAttackSpell(spellID)`](#c_spellisautoattackspellspellid)
- [`C_Spell.IsRangedAutoAttackSpell(spellID)`](#c_spellisrangedautoattackspellspellid)
- [`IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`](#isharmfulspellspell--ishelpfulspellspell)
@@ -430,6 +433,7 @@ build instructions.
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
@@ -3474,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
```
@@ -4428,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:
@@ -4961,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)`
@@ -5299,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,
@@ -5380,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
@@ -5437,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)`.
@@ -5705,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
@@ -8456,6 +8540,34 @@ matching the dual-signature shape used elsewhere in this backport
follows the same convention as `GetSpellName(slot, bookType)`:
`"spell"` (or any non-pet value) → player book, `"pet"` → pet book.
### `C_Spell.IsSpellInRange(spellIdentifier, targetUnit)`
Returns `true` if the spell is in range of `targetUnit`, `false` if
out of range, and `nil` when the range check doesn't apply — a
rangeless spell (self buff), or an unresolvable spell / unit.
`spellIdentifier` is a spellID, spell link, or the name of a spell in
the player's spellbook. `targetUnit` is a unit token.
```lua
C_Spell.IsSpellInRange(133, "target") -- Fireball → true / false
C_Spell.IsSpellInRange("Fireball", "target")
C_Spell.IsSpellInRange(1006, "target") -- Inner Fire (self) → nil
```
Uses the engine's own geometric range core (the same one the action
button range-out coloring uses via `IsActionInRange`): it derives the
spell's min/max range — folding in the target's bounding radius — and
compares center-to-center distance, so the boundary matches the
client exactly, melee and min-range ("too close") spells included.
Range-only, matching retail: it ignores line of sight, and it does
**not** reject wrong-faction targets (a friendly-only heal still
returns a range answer against an enemy). Check target validity
separately if you need it. Absent tokens (e.g. `"target"` with no
target) return `nil`; a genuinely unrecognized token string raises a
Lua error, same contract as `UnitHealth("garbage")`.
### `C_Spell.IsAutoAttackSpell(spellID)`
Returns `true` if the spell is the melee auto-attack — spell ID
@@ -10289,6 +10401,38 @@ which is left untouched).
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitInLineOfSight(unit)`
Returns `true` if the player has clear line of sight to `unit`, `false`
if world geometry (terrain or a building) blocks it, and `nil` when the
check can't apply — an absent / unresolvable token, or a non-unit
object. A ClassicAPI extension (retail has no such global).
```lua
if UnitInLineOfSight("target") then
-- clear shot
end
```
Traces a ray between the two units through the client's world-collision
geometry (`CWorld::Intersect`, flags `terrain + WMO`). Endpoints are
raised by each unit's collision-box height so a foot-to-foot ray doesn't
false-block on terrain, and the trace runs in two passes (level look,
then look up/down) for height mismatches — the same approach UnitXP_SP3
uses, implemented natively so it works with or without that addon (a
call routes harmlessly through UnitXP_SP3's hook when present).
Notes and limits:
- **Terrain + buildings (WMO) only** — M2 *doodads* (trees, small props)
are not tested (that flag combination crashes in some dungeons). This
matches vanilla server-side LoS, which also ignores most doodads.
- **Center/eye-line**, not a volume — a sliver of a unit peeking past a
corner reads as blocked if the eye-line itself is occluded.
- Pairs beyond ~150 yards report `false` (a guard against a
long-segment crash in the underlying trace); such units are usually
outside the client's object sync window anyway.
- `UnitInLineOfSight("player")` is `true` (you always see yourself).
### `UnitClassBase(unit)`
Returns `(classFile, classID)` — the locale-independent class
+77
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
@@ -951,6 +963,25 @@ enum Offsets {
// RECORD pointer, not a spellID.
FUN_GET_SPELL_DURATION = 0x006EA000,
// Pure spell range check against a target:
// `char __fastcall(void *caster /*ecx*/, void *target /*edx*/,
// int spellID, char *outMinMaxFlag)`.
// Computes the spell's min/max range via `FUN_006e3480` (which folds
// in the target's bounding radius), reads both objects' world
// positions (GetPosition, vtable+0x14), and compares center-to-center
// squared distance. Returns low byte 1 = in range, 0 = out (too close
// OR too far); `*outMinMaxFlag` is set to 1 on a max-range (too far)
// failure so the caller can pick a "too close" vs "out of range" error
// string — we ignore it. Both objects must be non-null (it dereferences
// the GetPosition result without a null check). This is the geometric
// core the action-button range coloring uses: `Script_IsActionInRange`
// (`0x004E7550`) → `FUN_004E56F0` → `FUN_006E4440` → here. We call it
// directly for `C_Spell.IsSpellInRange` rather than `FUN_006E4440`,
// which additionally gates on target type and folds "range N/A" into
// the same truthy return as "in range" (so it can't yield true/false/
// nil cleanly).
FUN_SPELL_RANGE_CHECK = 0x006E47B0,
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
@@ -1350,6 +1381,32 @@ enum Offsets {
// `0x0048BACF` / `0x0048BADC`.
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
// is a *vtable* byte-offset that happens to also be 0x14.)
OFF_CGOBJECT_TYPE_ID = 0x14,
OBJECT_TYPE_UNIT = 3,
OBJECT_TYPE_PLAYER = 4,
// World line-segment collision test (client-side LoS core):
// `char __fastcall(const Vec3 *start /*ecx*/, const Vec3 *end /*edx*/,
// int unused, Vec3 *hitOut, float *tOut, uint32 flags)`.
// Returns nonzero if the ray hit world geometry; `*tOut` is the hit
// fraction along start→end (t in [0,1] ⇒ hit lies between the two
// points ⇒ blocked; t outside [0,1] or no hit ⇒ clear). `flags`
// selects geometry: 0x100111 = terrain + WMO (the LoS combo;
// 0x100171 also tests M2 doodads but crashes in some dungeons per
// UnitXP_SP3). Thin `__fastcall` shim that tail-calls the real
// intersect `FUN_0069BFF0`; verified in-binary and cross-checked
// against UnitXP_SP3's `CWorld_Intersect` (same VA + flags).
FUN_CWORLD_INTERSECT = 0x00672170,
// Collision-box height of a unit, read from its CMovement sub-struct
// (`[unit + OFF_UNIT_MOVEMENT_INFO_PTR]`) at `+0xB4`. Used as the
// eye-height offset for LoS so foot-to-foot traces don't false-block
// on terrain. (CMovement pointer offset 0x118 = OFF_UNIT_MOVEMENT_INFO_PTR.)
OFF_CMOVEMENT_COLLISION_BOX_HEIGHT = 0xB4,
// Quest list array inside the `+0xE68` sub-struct. 20 fixed
// slots of 0xC (12) bytes each — `questID` at slot+0x00, and
// some flags/state in the remaining 8 bytes. Walked by
@@ -1538,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
@@ -4148,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;
}
}
}
+2 -59
View File
@@ -48,7 +48,6 @@
#include "item/Location.h"
#include <cstdint>
#include <cstring>
namespace Item::Use {
@@ -59,67 +58,10 @@ using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targe
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
@@ -130,12 +72,13 @@ int __fastcall Script_C_Item_UseItemByName(void *L) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &targetGuid, 0);
return 0;
+3 -43
View File
@@ -23,57 +23,17 @@
#include "Arg.h"
#include "Lookup.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstring>
namespace Spell::HasRange {
namespace {
// Spell.dbc RangeIndex field — mirrors `Script_GetSpellInfo`'s
// local constant. Future refactor: promote to `Offsets.h`.
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
// SpellRange.dbc record layout (verified, documented in CLAUDE.md
// under "Sub-DBC record layouts"):
// +0x00 id, +0x04 minRange (float), +0x08 maxRange (float), ...
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *spellRec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (spellRec == nullptr)
return false;
const uint32_t rangeIdx = *reinterpret_cast<const uint32_t *>(
spellRec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT,
rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
} // namespace
static int __fastcall Script_C_Spell_SpellHasRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
@@ -94,7 +54,7 @@ static int __fastcall Script_SpellHasRange(void *L) {
bookType = 1;
}
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
+58
View File
@@ -0,0 +1,58 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.IsSpellInRange(spellIdentifier, targetUnit)` — true if the
// spell is in range of the unit, false if out of range, nil when the
// range check doesn't apply (rangeless spell, or an unresolvable
// spell / unit). `spellIdentifier` is a spellID, spell link, or the
// name of a spell in the player's spellbook; `targetUnit` is a unit
// token ("target", "mouseover", "party1", …).
//
// Thin Lua wrapper over the shared `Spell::Range::PlayerVsUnit` core
// (see spell/Range.h) — the same range test behind `C_Item.IsItemInRange`
// (via the item's on-use spell). Range-only, ignoring line of sight and
// target faction, matching retail IsSpellInRange.
#include "Arg.h"
#include "Range.h"
#include "Game.h"
namespace Spell::InRange {
namespace {
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const int result = Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellInRange",
&Script_C_Spell_IsSpellInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Spell::InRange
+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
+57 -71
View File
@@ -149,64 +149,44 @@ using PlayerInfoLookup_t = const uint8_t *(__thiscall *)(
void *cache, uint32_t guidLo, uint32_t guidHi, uint64_t *cookie,
void *callback, void *userData, int retryFlag);
bool AllDigits(const char *s) {
if (s == nullptr || *s == '\0')
return false;
for (; *s != '\0'; ++s) {
if (*s < '0' || *s > '9')
return false;
}
return true;
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
// Resolve a GUID to its live CGUnit / CGPlayer object, or nullptr if
// the object isn't currently loaded. Unlike a unit-*token* resolve
// (`FUN_RESOLVE_UNIT_TOKEN`), this takes the GUID directly, so it works
// for any GUID `FUN_TOKEN_TO_GUID` produced — including `focus` /
// `nameplateN` — and never raises. The type arg is a bitmask of
// `(1 << objectType)`; `FUN_OBJECT_RESOLVE_BY_GUID` keeps the object
// only if its type flags intersect the mask, so unit|player matches a
// player, a pet, or an MC'd creature in one call.
const uint8_t *ResolveUnitOrPlayerByGuid(uint64_t guid) {
if (guid == 0)
return nullptr;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(kUnitOrPlayerMask, "UnitIsInMyGuild",
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0));
}
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Inline mirror of the engine's token-prefix tree inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`). The engine raises a Lua error
// on unknown input via `"Unknown unit name: %s"`; we want a
// non-erroring "is this a token?" predicate so we can dispatch only
// recognized tokens to the engine and treat everything else as a
// literal character name.
//
// Order matters: `partypet`/`raidpet` must be checked before
// `party`/`raid` because they share the shorter prefix.
bool LooksLikeKnownToken(const char *s) {
if (s == nullptr)
return false;
if (std::strcmp(s, "player") == 0) return true;
if (std::strcmp(s, "pet") == 0) return true;
if (std::strcmp(s, "target") == 0) return true;
if (std::strcmp(s, "mouseover") == 0) return true;
if (std::strcmp(s, "npc") == 0) return true;
if (HasPrefix(s, "partypet")) return AllDigits(s + 8);
if (HasPrefix(s, "raidpet")) return AllDigits(s + 7);
if (HasPrefix(s, "party")) return AllDigits(s + 5);
if (HasPrefix(s, "raid")) return AllDigits(s + 4);
return false;
}
// Resolves a known unit token to a player name via:
// 1. `FUN_TOKEN_TO_GUID` — OOR-safe (reads the SMSG_GROUP_LIST GUID
// tables, not CGObject lookups). Returns 0 for empty slots like
// `"party6"` when not in a 6-person group.
// 2. `FUN_PLAYER_INFO_LOOKUP` with a NULL callback — a pure
// NameCache read; returns the entry block for any GUID the
// engine has seen a `SMSG_NAME_QUERY_RESPONSE` for. Same path
// `GetPlayerInfoByGUID` uses.
// Resolves a player GUID to its character name via
// `FUN_PLAYER_INFO_LOOKUP` with a NULL callback — a pure NameCache
// read that returns the entry block for any GUID the engine has seen a
// `SMSG_NAME_QUERY_RESPONSE` for (same path `GetPlayerInfoByGUID`
// uses). This is the OOR-safe fallback: it works for a raid member in
// another zone whose CGUnit isn't loaded.
//
// Returns `nameBuf` on success (copied out — the entry's name pointer
// isn't guaranteed stable across reentrant cache writes), or nullptr
// if either step fails (empty token slot, NPC/creature GUID not in
// the player cache, or player not yet name-queried).
//
// Caller must have already prefix-gated the input via
// `LooksLikeKnownToken` — `FUN_TOKEN_TO_GUID` raises a Lua error for
// unrecognized tokens.
const char *TokenNameViaNameCache(const char *token, char (&nameBuf)[64]) {
auto toGuid = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
const uint64_t guid = toGuid(token);
// when the GUID isn't a player the engine has name-queried (NPC GUID,
// or not yet queried).
const char *NameFromGuid(uint64_t guid, char (&nameBuf)[64]) {
if (guid == 0)
return nullptr;
@@ -288,14 +268,25 @@ int __fastcall Script_UnitIsInMyGuild(void *L) {
return 1;
}
const bool inputIsToken = LooksLikeKnownToken(input);
// Resolve the input to a GUID via the non-throwing token resolver
// (`FUN_TOKEN_TO_GUID`, which our `Unit::TokenExtensions` hook also
// teaches `focus` / `nameplateN`). A recognized unit token yields
// its GUID; a literal character name — or an empty token slot like
// `"party6"` — yields 0, in which case the input is treated as a
// name for the roster strcmp. This replaces a hand-rolled mirror of
// the engine's token grammar: the engine already knows the grammar,
// and unlike its throwing sibling `FUN_RESOLVE_UNIT_TOKEN` this one
// just returns 0 on anything it doesn't recognize. (It's also what
// 3.3.5's `Script_UnitIsInMyGuild` does — resolve to a GUID first.)
auto toGuid = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
const uint64_t guid = toGuid(input);
if (inputIsToken) {
// Fast path: direct guild-key comparison for visible/loaded
// units. `resolve()` is safe to call now that we've prefix-
// gated — the engine raises a Lua error only on unrecognized
// tokens.
auto *unit = static_cast<const uint8_t *>(resolve(input));
const char *nameToFind;
char nameBuf[64];
if (guid != 0) {
// Fast path: GUID → live object → direct guild-key comparison.
// Covers visible/loaded units without touching the roster.
const uint8_t *unit = ResolveUnitOrPlayerByGuid(guid);
if (unit != nullptr) {
const uint32_t unitKey = ReadGuildKey(unit);
if (unitKey == playerKey) {
@@ -309,22 +300,17 @@ int __fastcall Script_UnitIsInMyGuild(void *L) {
}
// unitKey == 0: data not synced. Fall through to roster.
}
}
// Slow path: derive a name and strcmp against the roster.
const char *nameToFind;
char nameBuf[64];
if (inputIsToken) {
nameToFind = TokenNameViaNameCache(input, nameBuf);
// Slow path: GUID → name via the player NameCache → roster
// strcmp (handles OOR raid members in another zone).
nameToFind = NameFromGuid(guid, nameBuf);
if (nameToFind == nullptr) {
// Token resolved to an empty slot, an NPC GUID, or a
// player the engine hasn't name-queried yet. Either way,
// not a guildmate we can identify.
// Empty slot, an NPC GUID, or a player not yet name-queried
// — not a guildmate we can identify.
Game::Lua::PushNil(L);
return 1;
}
} else {
// Literal name — used directly for the roster strcmp.
// Not a recognized token — treat as a literal character name.
nameToFind = input;
}
+164
View File
@@ -0,0 +1,164 @@
// 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/>.
// `UnitInLineOfSight(unit)` — true if the player has clear line of sight
// to `unit`, false if world geometry blocks it, nil when the check can't
// apply (unresolvable / absent token, or a non-unit object). A ClassicAPI
// extension — retail has no such global — implemented natively on the
// engine's world-collision core so it works without UnitXP_SP3.
//
// Mechanism: trace a ray between the two units through the client's world
// geometry via `CWorld::Intersect` (`FUN_CWORLD_INTERSECT`). Endpoints are
// raised by each unit's collision-box height so a foot-to-foot ray doesn't
// false-block on terrain, and the trace runs in two passes (level look,
// then look up/down) exactly as UnitXP_SP3 does — this file mirrors that
// proven implementation (same VA, flags `0x100111` = terrain + WMO, and the
// 150yd guard against a long-segment crash it documented). We *call* the
// intersect; if UnitXP_SP3 is loaded it has that function hooked, but a
// call routes through its detour harmlessly (our flag isn't one it acts on).
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cmath>
#include <cstdint>
namespace Unit::LineOfSight {
namespace {
struct Vec3 {
float x, y, z;
};
// CWorld::Intersect(start, end, unused, hitOut, tOut, flags) -> hit?
using Intersect_t = char(__fastcall *)(const Vec3 *start, const Vec3 *end,
int unused, Vec3 *hitOut, float *tOut,
uint32_t flags);
constexpr uint32_t kLoSFlags = 0x100111; // terrain + WMO (LoS combo)
constexpr float kMaxTraceYards = 150.0f; // guard: raw call can crash beyond
constexpr float kSamePointYards = 0.05f;
int ObjectType(void *obj) {
return *reinterpret_cast<int *>(reinterpret_cast<uint8_t *>(obj) +
Offsets::OFF_CGOBJECT_TYPE_ID);
}
bool IsUnitOrPlayer(void *obj) {
const int t = ObjectType(obj);
return t == Offsets::OBJECT_TYPE_UNIT || t == Offsets::OBJECT_TYPE_PLAYER;
}
// Eye-height offset for a unit: its collision-box height, read out of the
// CMovement sub-struct. 0 if the movement pointer is absent (degrades to a
// foot-level trace rather than crashing).
float EyeHeight(void *unit) {
const uint32_t cmov = *reinterpret_cast<uint32_t *>(
reinterpret_cast<uint8_t *>(unit) + Offsets::OFF_UNIT_MOVEMENT_INFO_PTR);
if (cmov == 0)
return 0.0f;
return *reinterpret_cast<float *>(cmov +
Offsets::OFF_CMOVEMENT_COLLISION_BOX_HEIGHT);
}
// True if the segment a→b is clear of world geometry. `CWorld::Intersect`
// returns whether it hit and sets `t` to the hit fraction along a→b; a hit
// with t in [0,1] lies between the endpoints and blocks. The 150yd guard
// (over-range → treat as blocked) and same-point short-circuit mirror
// UnitXP_SP3's wrapper, which documented a crash on very long segments.
bool SegmentClear(const Vec3 &a, const Vec3 &b) {
const float dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
const float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
if (dist > kMaxTraceYards)
return false;
if (dist <= kSamePointYards)
return true;
Vec3 hit{};
float t = 1.0f;
auto intersect = reinterpret_cast<Intersect_t>(Offsets::FUN_CWORLD_INTERSECT);
const char hitGeometry = intersect(&a, &b, 0, &hit, &t, kLoSFlags);
if (hitGeometry == 0)
return true;
return !(t >= 0.0f && t <= 1.0f);
}
// Player↔unit LoS. Returns 1 = in sight, 0 = blocked, -1 = unknown
// (position unavailable). Two-pass eye-height handling: trace both
// endpoints at the shorter unit's eye height first (level look), then —
// only if that's blocked and the two heights differ — retry with each at
// its own eye height (look up/down).
int InSight(void *a, void *b) {
if (a == b)
return 1;
if (EyeHeight(a) > EyeHeight(b)) {
void *tmp = a;
a = b;
b = tmp;
}
float pa[3];
float pb[3];
if (!Unit::Position::Read(a, pa) || !Unit::Position::Read(b, pb))
return -1;
const float shortEye = EyeHeight(a);
const Vec3 la{pa[0], pa[1], pa[2] + shortEye};
const Vec3 lb{pb[0], pb[1], pb[2] + shortEye};
if (SegmentClear(la, lb))
return 1;
if (EyeHeight(a) == EyeHeight(b))
return 0;
const Vec3 ua{pa[0], pa[1], pa[2] + EyeHeight(a)};
const Vec3 ub{pb[0], pb[1], pb[2] + EyeHeight(b)};
return SegmentClear(ua, ub) ? 1 : 0;
}
int __fastcall Script_UnitInLineOfSight(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInLineOfSight(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
void *player = Unit::Position::ResolveToken("player");
void *unit = Unit::Position::ResolveToken(token);
if (player == nullptr || unit == nullptr || !IsUnitOrPlayer(player) ||
!IsUnitOrPlayer(unit)) {
Game::Lua::PushNil(L);
return 1;
}
const int sight = InSight(player, unit);
if (sight < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, sight != 0);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitInLineOfSight",
&Script_UnitInLineOfSight);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::LineOfSight