mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-15 15:40:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfe0b008c | ||
|
|
e9c2e7416a | ||
|
|
6641e5ac13 | ||
|
|
db1f00e0a3 | ||
|
|
82062309a9 | ||
|
|
2a218a0a76 | ||
|
|
e8a9003bf0 | ||
|
|
aa0279a600 | ||
|
|
a754d1ba22 | ||
|
|
d55877649f | ||
|
|
f55779f87e | ||
|
|
91e88d768a | ||
|
|
d358bd9515 | ||
|
|
4ba82e59e4 | ||
|
|
b05c15ffa7 | ||
|
|
4daf6b9364 | ||
|
|
4c6c9deae6 | ||
|
|
983cc3bdff | ||
|
|
18ac033381 | ||
|
|
81434f7014 | ||
|
|
22605dad64 |
@@ -39,9 +39,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.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.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [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`, `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` |
|
||||
|
||||
@@ -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".
|
||||
|
||||
+294
-21
@@ -194,6 +194,7 @@ build instructions.
|
||||
- [`IsLeftAltKeyDown()` / `IsRightAltKeyDown()`](#isleftaltkeydown--isrightaltkeydown)
|
||||
- [`IsModifierKeyDown()`](#ismodifierkeydown)
|
||||
- [`IsMouseButtonDown([button])`](#ismousebuttondownbutton)
|
||||
- [`GetMouseButtonClicked()`](#getmousebuttonclicked)
|
||||
|
||||
- [Instance](#instance)
|
||||
- [`GetInstanceInfo()`](#getinstanceinfo)
|
||||
@@ -226,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)
|
||||
@@ -348,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)
|
||||
@@ -359,6 +363,8 @@ build instructions.
|
||||
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
|
||||
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
|
||||
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
|
||||
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
|
||||
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
|
||||
|
||||
- [SpellBook](#spellbook)
|
||||
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
|
||||
@@ -426,6 +432,8 @@ build instructions.
|
||||
- [`UnitIsOtherPlayersPet(unit)`](#unitisotherplayerspetunit)
|
||||
- [`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)
|
||||
|
||||
@@ -3470,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
|
||||
```
|
||||
@@ -4294,6 +4302,38 @@ if IsMouseButtonDown("RightButton") then
|
||||
end
|
||||
```
|
||||
|
||||
### `GetMouseButtonClicked()`
|
||||
|
||||
Returns the name of the button responsible for the mouse handler
|
||||
currently running (`"LeftButton"`, `"RightButton"`, `"MiddleButton"`,
|
||||
`"Button4"`, `"Button5"`), or `nil` when no click is being handled.
|
||||
|
||||
Modern addons call this inside a mouse handler (`OnClick`,
|
||||
`OnMouseDown`, `OnMouseUp`, `OnDragStart`, …) to learn which button
|
||||
drove it — an alternative to reading vanilla's `arg1`, and readable
|
||||
from nested helper functions where `arg1` isn't in scope. The value
|
||||
is captured by the same `WH_GETMESSAGE` hook behind
|
||||
`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP`, then evicted a couple of
|
||||
frames later so — matching the real API — it reads `nil` outside a
|
||||
handler rather than lingering as the last click forever.
|
||||
|
||||
The button stays readable for the whole frame its click lands in (so
|
||||
multiple addons hooking the same `OnClick` all see it), and while a
|
||||
button is physically held (so `OnDragStart` reads it). It is a
|
||||
best-effort replica: WoW dispatches these handlers deferred from the
|
||||
OS message, so eviction is time-based (per-frame) rather than exactly
|
||||
bracketed around the handler as on retail. Values can therefore
|
||||
linger ~2 frames past a click — harmless, since it's only meaningful
|
||||
inside a mouse handler.
|
||||
|
||||
```lua
|
||||
button:SetScript("OnClick", function()
|
||||
if GetMouseButtonClicked() == "RightButton" then
|
||||
-- right-click behavior
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
## Instance
|
||||
|
||||
### `GetInstanceInfo()`
|
||||
@@ -4392,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:
|
||||
|
||||
@@ -4925,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)`
|
||||
@@ -5263,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,
|
||||
@@ -5344,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
|
||||
@@ -5401,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)`.
|
||||
@@ -5669,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
|
||||
@@ -7034,12 +7154,18 @@ Three population sources feed the cache when fully enabled:
|
||||
Storage:
|
||||
|
||||
- `WTF\Account\<account>\ClassicAPI.txt` — account-level settings
|
||||
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`).
|
||||
- `WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt` —
|
||||
per-realm cache. Tab-separated text, ~30 bytes/entry,
|
||||
hand-editable. Shared across all characters on the same
|
||||
account+realm (a 50-character bank alt's chat scraping doesn't
|
||||
double the storage cost).
|
||||
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`). The
|
||||
opt-in toggles stay per-account.
|
||||
- `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt` —
|
||||
per-realm cache, **shared across every account on the same
|
||||
server**. Keyed by the realmlist address (unambiguous server
|
||||
identity, can't collide across unrelated private servers) plus the
|
||||
realm name — so alts on different accounts pool into one file
|
||||
rather than each rebuilding their own. Tab-separated text,
|
||||
~30 bytes/entry, hand-editable. On first load with no shared file
|
||||
yet, a pre-existing per-account cache
|
||||
(`WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt`) is
|
||||
migrated in automatically.
|
||||
|
||||
### `GetPlayerInfoByGUID(guid)`
|
||||
|
||||
@@ -8414,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
|
||||
@@ -8924,6 +9078,55 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
|
||||
**`nil` times**. The player path is unchanged (full timing). Same
|
||||
placeholder fields as `C_Spell.UnitCastingInfo`.
|
||||
|
||||
### `C_Spell.GetSpellLevelInfo(spellID)`
|
||||
|
||||
Returns the raw `Spell.dbc` level fields for a spell:
|
||||
|
||||
```
|
||||
spellLevel, baseLevel, maxLevel = C_Spell.GetSpellLevelInfo(spellID)
|
||||
```
|
||||
|
||||
- `spellLevel` — the rank's effective level (drives the target-level rule
|
||||
below and per-level scaling).
|
||||
- `baseLevel` — the spell's base level.
|
||||
- `maxLevel` — the level at which per-level scaling caps (`0` = no cap).
|
||||
|
||||
Returns `nil` for an invalid / unpopulated spellID.
|
||||
|
||||
```lua
|
||||
C_Spell.GetSpellLevelInfo(14752) -- 30, 30, 40 (Divine Spirit Rank 1)
|
||||
C_Spell.GetSpellLevelInfo(14818) -- 40, 40, 50 (Divine Spirit Rank 2)
|
||||
```
|
||||
|
||||
### `GetSpellRequiredTargetLevel(spellID)`
|
||||
|
||||
Also `C_Spell.GetSpellRequiredTargetLevel(spellID)`. Returns the minimum
|
||||
level a **target** must be for this exact spell (rank) to apply — e.g.
|
||||
Divine Spirit Rank 1 can only be cast on a target level **20 or higher**.
|
||||
Returns `0` when the spell has no such restriction, or `nil` for an
|
||||
invalid spellID.
|
||||
|
||||
```lua
|
||||
GetSpellRequiredTargetLevel(14752) -- 20 (Divine Spirit Rank 1)
|
||||
GetSpellRequiredTargetLevel(14818) -- 30 (Divine Spirit Rank 2)
|
||||
GetSpellRequiredTargetLevel(1243) -- 0 (Fortitude Rank 1)
|
||||
GetSpellRequiredTargetLevel(10060) -- 0 (Power Infusion — single rank)
|
||||
GetSpellRequiredTargetLevel(133) -- 0 (Fireball — damage, not a buff)
|
||||
```
|
||||
|
||||
Mirrors the server's `SelectAuraRankForLevel` rule: a **ranked beneficial
|
||||
aura** is castable when `targetLevel + 10 >= spellLevel`, so the minimum
|
||||
target level is `spellLevel - 10`. The restriction is applied only when the
|
||||
spell is a ranked positive aura — `spellLevel > 10`, not passive, part of a
|
||||
rank chain, and applying an aura to a friendly/party/raid target (or an
|
||||
`APPLY_AREA_AURA_PARTY` effect). Below the minimum, the lowest rank fails
|
||||
with `SPELL_FAILED_LOWLEVEL`; higher ranks auto-downrank.
|
||||
|
||||
> The explicit `MinTargetLevel` / `MaxTargetLevel` spell fields the server
|
||||
> also enforces are **server-only** columns — they aren't present in the
|
||||
> 1.12 client's `Spell.dbc`, so the `spellLevel` rule is the only
|
||||
> client-readable target-level mechanism.
|
||||
|
||||
## SpellBook
|
||||
|
||||
Functions keyed on the player or pet **spellbook** (slot indices,
|
||||
@@ -10160,6 +10363,76 @@ mouseover, etc.). For raid members in a different zone or otherwise
|
||||
not in the engine's sync window, the position virtual returns null
|
||||
and the function reports `(false, false)`.
|
||||
|
||||
### `UnitDistanceSquared(unit)`
|
||||
|
||||
Returns `(distanceSquared, checkedPosition)` — the **squared** world
|
||||
distance (yards²) from the player to `unit`, and a flag indicating
|
||||
whether both positions were available.
|
||||
|
||||
```lua
|
||||
local distSq, checked = UnitDistanceSquared("target")
|
||||
if checked and distSq <= 30 * 30 then
|
||||
-- target is within 30 yards
|
||||
end
|
||||
```
|
||||
|
||||
| Return | Meaning |
|
||||
|--------|---------|
|
||||
| `distanceSquared` | Squared distance player→unit. `0` when `checkedPosition` is `false` (a placeholder — matches retail's "always a number" shape). |
|
||||
| `checkedPosition` | `true` when both positions were read. `false` when `unit`'s position isn't available (empty `partyN` slot, no target, raid member outside the sync window, etc.). |
|
||||
|
||||
The value is **squared** on purpose: nearly all distance logic is a
|
||||
threshold compare (`distSq <= range * range`) or a nearest-unit sort,
|
||||
neither of which needs the square root — so retail exposes only the
|
||||
squared form (added 5.0.4) and never a plain `UnitDistance`. Take
|
||||
`math.sqrt(distanceSquared)` only when you need a yard number to show a
|
||||
human.
|
||||
|
||||
Reads world positions via the `CGObject::GetPosition` vtable virtual
|
||||
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
|
||||
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
|
||||
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
|
||||
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
|
||||
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
|
||||
which is left untouched).
|
||||
|
||||
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
|
||||
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
|
||||
> 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
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@ namespace Lua {
|
||||
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
|
||||
F(Resume, lua_resume, LUA_RESUME) \
|
||||
F(Yield, lua_yield, LUA_YIELD) \
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
|
||||
F(SetN, luaL_setn, LUAL_SETN)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
|
||||
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
|
||||
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
|
||||
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
|
||||
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
|
||||
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
|
||||
extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
extern const luaL_setn_t SetN;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
|
||||
@@ -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
|
||||
@@ -3476,6 +3544,17 @@ enum Offsets {
|
||||
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
|
||||
// calls) and calls `lua_error`. Doesn't return.
|
||||
LUAL_ARG_ERROR = 0x6F4810,
|
||||
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
|
||||
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
|
||||
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
|
||||
// `n` field exists, else `sizes[t] = n` in the registry's weak
|
||||
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
|
||||
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
|
||||
// Needed by `table.wipe` to reset the length after clearing keys —
|
||||
// the length is out-of-band from the keys, so a `lua_next` +
|
||||
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
|
||||
// appends past the wiped slots.
|
||||
LUAL_SETN = 0x6F4EA0,
|
||||
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
|
||||
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
|
||||
// otherwise. Used by `coroutine.create` to reject C functions
|
||||
@@ -4137,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
|
||||
|
||||
@@ -492,6 +492,31 @@ void PushFromCache(void *L, const uint8_t *unit,
|
||||
c.casterGuid);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
// descriptor holds at least one raw aura the unit is in view and fully synced,
|
||||
// so it's authoritative — any cached entry for the unit whose spell isn't in
|
||||
// the array was removed while we couldn't see it (e.g. cancelled out of range,
|
||||
// so no OnAuraRemoved reached us) and must be dropped before the fallback
|
||||
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
|
||||
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
|
||||
// but flag-hidden is still legitimately on the unit. Skips when the array is
|
||||
// empty — that can't be told apart from an out-of-range descriptor drop, and
|
||||
// reconciling then would wipe still-valid out-of-range entries.
|
||||
void ReconcileCache(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
uint32_t present[Offsets::UNIT_AURA_TOTAL];
|
||||
int n = 0;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = ReadSpellID(unit, slot);
|
||||
if (id != 0)
|
||||
present[n++] = id;
|
||||
}
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
// range. The cache fallback is only trustworthy when the descriptor has been
|
||||
// wholesale-cleared: the two states it exists for — rogue stealth and party
|
||||
@@ -559,6 +584,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return false; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
@@ -589,6 +615,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
|
||||
+29
-4
@@ -325,10 +325,18 @@ const Game::HookAutoRegister _hookAuraStacks{
|
||||
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
|
||||
|
||||
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
|
||||
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
|
||||
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
|
||||
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
|
||||
// stack); `slot` is unused here — we evict by spellId.
|
||||
// dispelled, was cancelled by its owner, or was replaced by a higher rank
|
||||
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
|
||||
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
|
||||
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
|
||||
// here — we evict by spellId.
|
||||
//
|
||||
// Always evict, even for a death-persistent aura (flask, world buff) on a
|
||||
// dead unit: the server keeps those through death WITHOUT changing any
|
||||
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
|
||||
// for one is therefore genuine — e.g. the owner cancelled the flask while
|
||||
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
|
||||
// whose SetUnitAura tooltip is empty.
|
||||
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId);
|
||||
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
|
||||
@@ -361,6 +369,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
return false;
|
||||
}
|
||||
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
|
||||
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
|
||||
return;
|
||||
for (auto &e : g_cache) {
|
||||
if (!e.used || e.targetGuid != unitGuid)
|
||||
continue;
|
||||
bool present = false;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (presentSpellIds[i] == e.spellId) {
|
||||
present = true;
|
||||
break;
|
||||
}
|
||||
if (!present)
|
||||
e.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
|
||||
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
|
||||
return 0;
|
||||
|
||||
@@ -50,6 +50,18 @@ struct CachedAura {
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
};
|
||||
|
||||
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
|
||||
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
|
||||
// authoritative descriptor when it is back in view (a populated aura array
|
||||
// means the unit is fully synced): an entry the descriptor no longer lists
|
||||
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
|
||||
// while out of our range, whose `OnAuraRemoved` we never received — so drop
|
||||
// it before the descriptor-drop fallback resurfaces it as a phantom. The
|
||||
// caller must only invoke this when the descriptor is populated (count > 0);
|
||||
// an empty array can't distinguish "out of range" from "genuinely buffless",
|
||||
// so reconciling then would wrongly wipe still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
// written. The classification is recorded from the aura's descriptor slot at
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +24,26 @@
|
||||
// as the events (single source of truth — events firing and the
|
||||
// state being readable happen at the same point in the pipeline).
|
||||
//
|
||||
// `GetMouseButtonClicked()` — the button name of the most recent
|
||||
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
|
||||
// when no click is being handled. Modern addons call it inside a
|
||||
// mouse handler to learn which button drove it; the value is evicted
|
||||
// shortly after, so — like the real API — it reads nil outside a
|
||||
// handler rather than returning a stale last-click forever.
|
||||
//
|
||||
// Modern WoW backs this with a frame-manager field set right before a
|
||||
// mouse handler runs and cleared right after (verified in 3.3.5:
|
||||
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
|
||||
// field is null). We can't bracket the handler the same way here: the
|
||||
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
|
||||
// dispatches the frame-script mouse handlers *deferred* (during the
|
||||
// frame update, not the message pump). So instead we capture the
|
||||
// button on the message and evict it from the per-frame `WorldTick`
|
||||
// once a couple of quiet ticks pass with no button held — the value
|
||||
// survives the whole frame its click lands in (so every handler,
|
||||
// including multiple addons hooking one `OnClick`, sees it) then
|
||||
// clears. A held button keeps it set so `OnDragStart` still reads it.
|
||||
//
|
||||
// Same `WH_GETMESSAGE` thread-message hook pattern as
|
||||
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
|
||||
// messages on the engine's main thread *before* dispatch. The hook
|
||||
@@ -33,6 +53,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
|
||||
|
||||
uint32_t g_mouseButtonMask = 0;
|
||||
|
||||
// Name of the button from the most recent mouse-button message, or
|
||||
// nullptr when no click is being handled. Points at one of
|
||||
// DecodeButton's string literals (stable for the process lifetime),
|
||||
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
|
||||
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
|
||||
// caller with no extra branch. Set on each button message; evicted by
|
||||
// EvictTick (see the header note on GetMouseButtonClicked).
|
||||
const char *g_lastButton = nullptr;
|
||||
|
||||
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
|
||||
// tell a just-clicked frame from a quiet one. Number of consecutive
|
||||
// quiet ticks (no activity, nothing held) required before eviction —
|
||||
// >1 so a handler dispatched a frame after its message still reads
|
||||
// the button.
|
||||
bool g_buttonActivity = false;
|
||||
int g_quietTicks = 0;
|
||||
constexpr int kQuietTicksToEvict = 2;
|
||||
|
||||
HHOOK g_msgHook = nullptr;
|
||||
|
||||
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
|
||||
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
if (name == nullptr)
|
||||
return;
|
||||
|
||||
// Capture for GetMouseButtonClicked on both press and release —
|
||||
// during an OnClick / OnMouseUp handler the "clicked" button is the
|
||||
// one just released, same as the press for a normal click.
|
||||
g_lastButton = name;
|
||||
g_buttonActivity = true;
|
||||
g_quietTicks = 0;
|
||||
|
||||
const int bitIdx = NameToBitIdx(name);
|
||||
if (bitIdx >= 0) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetMouseButtonClicked(void *L) {
|
||||
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
|
||||
// callers outside a mouse handler see nil, matching the modern API.
|
||||
Game::Lua::PushString(L, g_lastButton);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
|
||||
// world tick. While a button is held we keep the value (drag handlers
|
||||
// still read it) and reset the quiet counter. A frame that saw a fresh
|
||||
// button message consumes its activity flag but doesn't yet evict —
|
||||
// that one-frame grace lets a handler dispatched the frame after its
|
||||
// message still read the button. Only after kQuietTicksToEvict ticks
|
||||
// with nothing held and no activity do we clear, so outside a click
|
||||
// GetMouseButtonClicked reads nil instead of a stale last-click.
|
||||
void EvictTick() {
|
||||
if (g_lastButton == nullptr)
|
||||
return;
|
||||
if (g_mouseButtonMask != 0) {
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (g_buttonActivity) {
|
||||
g_buttonActivity = false;
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (++g_quietTicks >= kQuietTicksToEvict) {
|
||||
g_lastButton = nullptr;
|
||||
g_quietTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
// Only process when the message is being removed from the queue
|
||||
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
|
||||
@@ -157,11 +236,14 @@ void InstallHook() {
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
|
||||
&Script_IsMouseButtonDown);
|
||||
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
|
||||
&Script_GetMouseButtonClicked);
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+117
-22
@@ -23,13 +23,19 @@
|
||||
// parse callbacks, and the registry handles load/save so neither
|
||||
// this module nor `Charlist::Order` clobbers the other's lines.
|
||||
//
|
||||
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents. v2 format. One entry per line:
|
||||
// `guidHi guidLo classID raceID sex name`
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents, SHARED across every account on the
|
||||
// same server. Keyed by realmlist address (unambiguous server
|
||||
// identity — can't collide across unrelated private servers) plus
|
||||
// the realm name, rather than by account, so alts on different
|
||||
// accounts pool into one file. Not under `Account\`, so we create
|
||||
// the directory tree ourselves. v4 format. One entry per line:
|
||||
// `name guid classID raceID sex`
|
||||
// (fields tab-separated). Loaded once the realm name is
|
||||
// available post-login. Saved when (a) a recent write hasn't
|
||||
// been flushed AND (b) at least 30 seconds have elapsed since
|
||||
// the prior save, OR (c) DLL teardown / `Flush()` is called.
|
||||
// available post-login (migrating a pre-existing per-account file
|
||||
// in on first load). Saved when (a) a recent write hasn't been
|
||||
// flushed AND (b) at least 30 seconds have elapsed since the prior
|
||||
// save, OR (c) DLL teardown / `Flush()` is called.
|
||||
//
|
||||
// Population sources:
|
||||
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
|
||||
@@ -69,6 +75,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <istream>
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
|
||||
|
||||
// ----- path resolution -----
|
||||
|
||||
// realmList CVar value (the realmlist address, e.g.
|
||||
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
|
||||
// friendly realm name it can't collide across unrelated private servers,
|
||||
// so it's the outer key for the shared cache path. Mirrors
|
||||
// `Security::CredStore`'s realm scoping.
|
||||
const char *ReadRealmList() {
|
||||
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
|
||||
return reinterpret_cast<GetRealmList_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
|
||||
}
|
||||
|
||||
// Filesystem-safe path component. We create these directories ourselves
|
||||
// (WoW doesn't), so any character Windows rejects in a path — or that
|
||||
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
|
||||
// Deterministic, so a realm always maps to the same folder.
|
||||
std::string SanitizeComponent(const char *s) {
|
||||
std::string out;
|
||||
for (const char *p = s; p != nullptr && *p; ++p) {
|
||||
const char c = *p;
|
||||
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
|
||||
c == '_';
|
||||
out.push_back(safe ? c : '_');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shared, server-specific cache path:
|
||||
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
|
||||
// Not under Account\, so alts on different accounts of the same server
|
||||
// share one pool. Empty (defer) until both realmlist + realm are known.
|
||||
std::string CachePath() {
|
||||
const char *realmList = ReadRealmList();
|
||||
if (realmList == nullptr || realmList[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
std::string out = "WTF\\ClassicAPI\\";
|
||||
out += SanitizeComponent(realmList);
|
||||
out += '\\';
|
||||
out += SanitizeComponent(realm);
|
||||
out += "\\ClassicAPI_NameCache.txt";
|
||||
return out;
|
||||
}
|
||||
|
||||
// The pre-shared per-account path, used only to migrate an existing
|
||||
// cache into the shared location on first load. Built exactly as it was
|
||||
// written (raw account + realm, unsanitized) so it matches the file
|
||||
// sitting in WoW's own folders.
|
||||
std::string OldCachePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
@@ -154,6 +211,21 @@ std::string CachePath() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
|
||||
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
|
||||
// directories, so a save into a fresh realm folder would silently fail
|
||||
// without this. CreateDirectory on an existing dir no-ops.
|
||||
void EnsureParentDirs(const std::string &filePath) {
|
||||
for (size_t pos = 0;;) {
|
||||
const size_t slash = filePath.find('\\', pos);
|
||||
if (slash == std::string::npos)
|
||||
break; // remainder is the filename
|
||||
if (slash > 0)
|
||||
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
|
||||
pos = slash + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- account settings (registered with Settings::Account) -----
|
||||
|
||||
void SettingsReset() {
|
||||
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
|
||||
return changed;
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open())
|
||||
return; // first run for this realm
|
||||
|
||||
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
|
||||
// it's the primary key; GUID is a peripheral field carried for
|
||||
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
|
||||
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
|
||||
// into the store. Name first since it's the primary key; GUID is a
|
||||
// peripheral field carried for GUID-keyed lookups. Variable-width hex
|
||||
// GUID (no leading zeros). Shared by the normal load and the one-time
|
||||
// migration from the old per-account file.
|
||||
void LoadFromStream(std::istream &file) {
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
|
||||
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
|
||||
}
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (file.is_open()) {
|
||||
LoadFromStream(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// No shared file yet. One-time migration: seed from the old
|
||||
// per-account file if it exists, then mark dirty so the next save
|
||||
// writes the merged data into the shared path. Only when the shared
|
||||
// file is absent — once it exists it's authoritative (a second
|
||||
// account's old-only entries just repopulate via scan / SMSG).
|
||||
const std::string oldPath = OldCachePath();
|
||||
if (oldPath.empty())
|
||||
return;
|
||||
std::ifstream oldFile(oldPath);
|
||||
if (oldFile.is_open()) {
|
||||
LoadFromStream(oldFile);
|
||||
g_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SaveCache() {
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return;
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
|
||||
// the tree first, else the ofstream open below silently fails.
|
||||
EnsureParentDirs(path);
|
||||
const std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
@@ -319,7 +414,7 @@ void SaveCache() {
|
||||
// sort/scan key when hand-editing. GUID is variable-width
|
||||
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
|
||||
// GUID ever lands here.
|
||||
out << "# ClassicAPI name cache v4 (per-realm)\n";
|
||||
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
|
||||
out << "# name\tguid\tclassID\traceID\tsex\n";
|
||||
for (const auto &kv : g_entries) {
|
||||
const std::string &name = kv.first;
|
||||
|
||||
+3
-43
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
|
||||
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
|
||||
}
|
||||
|
||||
// ---- Target-level gate for ranked beneficial auras ----------------------
|
||||
//
|
||||
// A ranked beneficial aura can't be applied to a target too low for the
|
||||
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
|
||||
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
|
||||
// is: a ranked positive aura is castable when `targetLevel + 10 >=
|
||||
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
|
||||
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
|
||||
// downrank. The gate only applies to *ranked* *positive* *auras* — see
|
||||
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
|
||||
//
|
||||
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
|
||||
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
|
||||
// the spellLevel rule is the only client-readable target-level mechanism.)
|
||||
|
||||
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
|
||||
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
|
||||
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
|
||||
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
|
||||
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
|
||||
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
|
||||
|
||||
int LocaleIndex() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
}
|
||||
|
||||
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
|
||||
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
|
||||
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
|
||||
// a general "is helpful" set — this is the exact gate the rank rule uses
|
||||
// (self-cast / pet targets don't count).
|
||||
bool IsPositiveRankTarget(uint32_t t) {
|
||||
switch (t) {
|
||||
case 21: // TARGET_UNIT_FRIEND
|
||||
case 35: // TARGET_UNIT_PARTY
|
||||
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
|
||||
case 57: // TARGET_UNIT_RAID
|
||||
case 61: // TARGET_UNIT_RAID_AND_CLASS
|
||||
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
|
||||
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
|
||||
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
|
||||
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
|
||||
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
|
||||
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
|
||||
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// True if the record carries a localized "Rank N" string — the client's
|
||||
// marker for a spell in a ranked line (single-rank spells leave it empty).
|
||||
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
|
||||
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
|
||||
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
|
||||
bool HasRankString(const uint8_t *record) {
|
||||
const char *rank = *reinterpret_cast<const char *const *>(
|
||||
record + OFF_SPELL_RANK + LocaleIndex() * 4);
|
||||
return rank != nullptr && *rank != '\0';
|
||||
}
|
||||
|
||||
// Minimum target level required to apply this exact spell (rank), or 0 if
|
||||
// the spell isn't subject to the ranked-positive-aura target-level rule.
|
||||
int RequiredTargetLevel(const uint8_t *record) {
|
||||
const int spellLevel =
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
|
||||
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
|
||||
return 0;
|
||||
const uint32_t attr =
|
||||
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
|
||||
if (attr & SPELL_ATTR_PASSIVE)
|
||||
return 0;
|
||||
if (!HasRankString(record)) // single-rank spell → not gated
|
||||
return 0;
|
||||
|
||||
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
|
||||
auto *targetA =
|
||||
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
|
||||
bool positiveAura = false;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
|
||||
IsPositiveRankTarget(targetA[i])) ||
|
||||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
|
||||
positiveAura = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!positiveAura)
|
||||
return 0;
|
||||
return spellLevel - 10;
|
||||
}
|
||||
|
||||
int ArgSpellID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
return static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
}
|
||||
|
||||
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
|
||||
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
|
||||
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
|
||||
// spellID.
|
||||
int __fastcall Script_GetSpellLevelInfo(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
|
||||
return 3;
|
||||
}
|
||||
|
||||
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
|
||||
// be for this spell (rank) to apply (0 = no restriction). nil for an
|
||||
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
|
||||
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
|
||||
// at which a spell becomes available (the `BaseLevel` field in
|
||||
// Spell.dbc). Direct read off the record — no class/race filtering,
|
||||
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
|
||||
&Script_GetSpellLevelLearned);
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
|
||||
&Script_GetCurrentLevelSpells);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
|
||||
&Script_GetSpellLevelInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -29,6 +29,11 @@ namespace Table::Wipe {
|
||||
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
|
||||
// so the pattern ports verbatim.
|
||||
//
|
||||
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
|
||||
// `table.insert`/`getn` length out of band from its keys (see the
|
||||
// `luaL_setn` note below), so after the key-clear loop we must also
|
||||
// reset that counter — the 5.1 twipe had no such legacy to clear.
|
||||
//
|
||||
// Stack discipline matches 3.3.5 to the call:
|
||||
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
|
||||
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
|
||||
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
|
||||
Game::Lua::RawSet(L, 1); // t[key] = nil
|
||||
}
|
||||
|
||||
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
|
||||
// `getn` length is stored OUT of band from its keys (in a `t.n`
|
||||
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
|
||||
// loop above — which only clears keys — leaves it at its old value.
|
||||
// Without this, `table.insert` after a wipe appends at
|
||||
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
|
||||
// wipe doesn't need this (no getn legacy), which is why the ported
|
||||
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
|
||||
// inserts resume at `t[1]`.
|
||||
Game::Lua::SetN(L, 1, 0);
|
||||
|
||||
// The table is still at stack[1] and is the result we return.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
return 1;
|
||||
|
||||
+86
-16
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
|
||||
// English fallback labels, used when the client doesn't define the
|
||||
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
|
||||
// mirrors pfUI's eqcompare so the deltas read consistently on this
|
||||
// client. Percent stats keep a "%" suffix since the values are native
|
||||
// vanilla percentages.
|
||||
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
|
||||
// "%" to the value ("-5% Block"), so the unit is correct regardless of
|
||||
// which label (Blizzard `_SHORT` or this fallback) is used.
|
||||
struct KeyLabel {
|
||||
const char *key;
|
||||
const char *label;
|
||||
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
|
||||
{"RESISTANCE6_NAME", "Arcane Resistance"},
|
||||
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
|
||||
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
|
||||
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
|
||||
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
|
||||
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
|
||||
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry %"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block %"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block"},
|
||||
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
|
||||
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
|
||||
{"ITEM_DELTA_DESCRIPTION",
|
||||
"If you replace this item, the following stat changes will occur:"},
|
||||
};
|
||||
|
||||
// Percent-valued stat keys: the block/dodge/parry *chances* and the
|
||||
// crit/hit percentages. Vanilla stores these as native percentages (a
|
||||
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
|
||||
// Block". The "%" is attached to the NUMBER rather than the label, so it
|
||||
// stays correct in every locale and requires no `_SHORT` change.
|
||||
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
|
||||
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
|
||||
bool IsPercentStat(const char *key) {
|
||||
static const char *const kPercent[] = {
|
||||
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
|
||||
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
|
||||
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
|
||||
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
|
||||
"ITEM_MOD_BLOCK_RATING",
|
||||
};
|
||||
for (const char *k : kPercent)
|
||||
if (std::strcmp(k, key) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *FallbackLabel(const char *key) {
|
||||
for (const auto &e : kFallbackLabels)
|
||||
if (std::strcmp(e.key, key) == 0)
|
||||
@@ -199,22 +221,63 @@ bool EndsWithShort(const char *key) {
|
||||
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
|
||||
}
|
||||
|
||||
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
|
||||
// string. Direct globals-table access, no pcall. Returns whether it took.
|
||||
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
|
||||
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
|
||||
// globals-table access, no pcall. Returns whether a non-empty string was
|
||||
// copied into `out`.
|
||||
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
|
||||
const int saved = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
|
||||
? Game::Lua::ToString(L, -1)
|
||||
: nullptr;
|
||||
const bool ok = (g != nullptr && *g != '\0' && !HasFormatSpecifier(g));
|
||||
const bool ok = (g != nullptr && *g != '\0');
|
||||
if (ok)
|
||||
std::snprintf(out, outSize, "%s", g);
|
||||
Game::Lua::SetTop(L, saved);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
|
||||
// string. Returns whether it took.
|
||||
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
|
||||
char tmp[192];
|
||||
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
|
||||
return false;
|
||||
std::snprintf(out, outSize, "%s", tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Formats a signed value token (e.g. "-5") as a localized percentage via
|
||||
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
|
||||
// format is NEVER passed to snprintf — we substitute `num` into its first
|
||||
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
|
||||
// hostile or unexpected format can't misread the argument list. Falls back
|
||||
// to a plain "%" suffix when the global is absent.
|
||||
void FormatPercent(void *L, const char *num, char *out, int outSize) {
|
||||
char fmt[64];
|
||||
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
|
||||
std::snprintf(out, outSize, "%s%%", num);
|
||||
return;
|
||||
}
|
||||
int o = 0;
|
||||
bool inserted = false;
|
||||
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
|
||||
if (p[0] == '%' && p[1] == '%') { // literal percent
|
||||
out[o++] = '%';
|
||||
++p;
|
||||
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
|
||||
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
|
||||
out[o++] = *q;
|
||||
++p; // consume the conversion letter (s / d)
|
||||
inserted = true;
|
||||
} else {
|
||||
out[o++] = *p;
|
||||
}
|
||||
}
|
||||
out[o] = '\0';
|
||||
}
|
||||
|
||||
// Resolve a display label for a stat `key` into `out`, respecting
|
||||
// localization. The rating/regen tokens GetItemStats keys on
|
||||
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
|
||||
@@ -285,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
|
||||
if (v == 0)
|
||||
continue;
|
||||
LabelFor(L, acc[i].key, label, sizeof(label));
|
||||
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
|
||||
char num[48];
|
||||
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
|
||||
if (IsPercentStat(acc[i].key)) {
|
||||
char pct[64];
|
||||
FormatPercent(L, num, pct, sizeof(pct));
|
||||
std::snprintf(num, sizeof(num), "%s", pct);
|
||||
}
|
||||
std::snprintf(line, sizeof(line), "%s %s", num, label);
|
||||
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-71
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+62
-14
@@ -8,11 +8,21 @@
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
|
||||
// range check, modern's `UnitInRange` shape. Vanilla 1.12 doesn't
|
||||
// ship the function but has all the underlying machinery — the
|
||||
// same `GetPosition` virtual (vtable slot 5) `CheckInteractDistance`
|
||||
// uses, applied with a fixed 40-yard threshold.
|
||||
// Player↔unit proximity, modern's shape. Vanilla 1.12 ships neither
|
||||
// function but has all the underlying machinery — the object's
|
||||
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
|
||||
//
|
||||
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
|
||||
// range check (fixed threshold on the squared distance).
|
||||
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
|
||||
// the raw squared world distance player→unit. Squared because
|
||||
// nearly every consumer compares against a threshold (`distSq <=
|
||||
// range*range`) or ranks by nearest, neither of which needs the
|
||||
// sqrt; that's why retail exposes only the squared form (added
|
||||
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
|
||||
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
|
||||
// but self-contained (reads the position in C, no sibling-DLL
|
||||
// dependency, no clash with SuperWoW's own `UnitPosition`).
|
||||
|
||||
#include "Game.h"
|
||||
#include "unit/Position.h"
|
||||
@@ -26,6 +36,24 @@ namespace {
|
||||
constexpr float kRangeYards = 40.0f;
|
||||
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
|
||||
|
||||
// Squared world distance from the player to `token`'s unit. Returns true
|
||||
// and writes *outSq on success; false when either position is unavailable
|
||||
// (unresolved / absent token, or an object with no known position yet —
|
||||
// e.g. a party member outside the client's sync range). This false is
|
||||
// exactly the `checkedPosition = false` case retail flags.
|
||||
bool PlayerToUnitDistSq(const char *token, float *outSq) {
|
||||
float unitPos[3] = {};
|
||||
float playerPos[3] = {};
|
||||
if (!Unit::Position::ReadToken(token, unitPos) ||
|
||||
!Unit::Position::ReadToken("player", playerPos))
|
||||
return false;
|
||||
const float dx = unitPos[0] - playerPos[0];
|
||||
const float dy = unitPos[1] - playerPos[1];
|
||||
const float dz = unitPos[2] - playerPos[2];
|
||||
*outSq = dx * dx + dy * dy + dz * dz;
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitInRange(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
|
||||
@@ -44,27 +72,47 @@ int __fastcall Script_UnitInRange(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
float unitPos[3] = {};
|
||||
float playerPos[3] = {};
|
||||
if (!Unit::Position::ReadToken(token, unitPos) ||
|
||||
!Unit::Position::ReadToken("player", playerPos)) {
|
||||
float distSq = 0.0f;
|
||||
if (!PlayerToUnitDistSq(token, &distSq)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const float dx = unitPos[0] - playerPos[0];
|
||||
const float dy = unitPos[1] - playerPos[1];
|
||||
const float dz = unitPos[2] - playerPos[2];
|
||||
const float distSq = dx * dx + dy * dy + dz * dz;
|
||||
|
||||
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `UnitDistanceSquared("unit") → (distanceSquared, checkedPosition)`.
|
||||
// Unlike UnitInRange there's no self-quirk: `UnitDistanceSquared("player")`
|
||||
// is a legitimate `(0, true)`. On a position miss returns `(0, false)` —
|
||||
// matching retail's "always a number" shape; consumers must branch on
|
||||
// `checkedPosition`, since a real `0` (self, or exactly co-located units)
|
||||
// is indistinguishable from the miss placeholder by value alone.
|
||||
int __fastcall Script_UnitDistanceSquared(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitDistanceSquared(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
|
||||
float distSq = 0.0f;
|
||||
if (!PlayerToUnitDistSq(token, &distSq)) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(distSq));
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitInRange", &Script_UnitInRange);
|
||||
Game::Lua::RegisterGlobalFunction("UnitDistanceSquared",
|
||||
&Script_UnitDistanceSquared);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
Reference in New Issue
Block a user