mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-14 15:10:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a754d1ba22 | ||
|
|
d55877649f | ||
|
|
f55779f87e | ||
|
|
91e88d768a | ||
|
|
d358bd9515 |
@@ -54,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Table](docs/API.md#table) | `table.wipe` |
|
||||
@@ -62,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
|
||||
@@ -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".
|
||||
|
||||
+62
@@ -349,6 +349,7 @@ build instructions.
|
||||
- [`C_Spell.IsCurrentSpell(spellIdentifier)`](#c_spelliscurrentspellspellidentifier)
|
||||
- [`C_Spell.IsSelfBuff(spellID)`](#c_spellisselfbuffspellid)
|
||||
- [`C_Spell.SpellHasRange(spellIdentifier)` / `SpellHasRange(slot, bookType)`](#c_spellspellhasrangespellidentifier--spellhasrangeslot-booktype)
|
||||
- [`C_Spell.IsSpellInRange(spellIdentifier, targetUnit)`](#c_spellisspellinrangespellidentifier-targetunit)
|
||||
- [`C_Spell.IsAutoAttackSpell(spellID)`](#c_spellisautoattackspellspellid)
|
||||
- [`C_Spell.IsRangedAutoAttackSpell(spellID)`](#c_spellisrangedautoattackspellspellid)
|
||||
- [`IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`](#isharmfulspellspell--ishelpfulspellspell)
|
||||
@@ -430,6 +431,7 @@ build instructions.
|
||||
- [`UnitStandState(unit)`](#unitstandstateunit)
|
||||
- [`UnitInRange(unit)`](#unitinrangeunit)
|
||||
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
|
||||
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
|
||||
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
|
||||
- [`UnitPowerType(unit)`](#unitpowertypeunit)
|
||||
|
||||
@@ -8456,6 +8458,34 @@ matching the dual-signature shape used elsewhere in this backport
|
||||
follows the same convention as `GetSpellName(slot, bookType)`:
|
||||
`"spell"` (or any non-pet value) → player book, `"pet"` → pet book.
|
||||
|
||||
### `C_Spell.IsSpellInRange(spellIdentifier, targetUnit)`
|
||||
|
||||
Returns `true` if the spell is in range of `targetUnit`, `false` if
|
||||
out of range, and `nil` when the range check doesn't apply — a
|
||||
rangeless spell (self buff), or an unresolvable spell / unit.
|
||||
|
||||
`spellIdentifier` is a spellID, spell link, or the name of a spell in
|
||||
the player's spellbook. `targetUnit` is a unit token.
|
||||
|
||||
```lua
|
||||
C_Spell.IsSpellInRange(133, "target") -- Fireball → true / false
|
||||
C_Spell.IsSpellInRange("Fireball", "target")
|
||||
C_Spell.IsSpellInRange(1006, "target") -- Inner Fire (self) → nil
|
||||
```
|
||||
|
||||
Uses the engine's own geometric range core (the same one the action
|
||||
button range-out coloring uses via `IsActionInRange`): it derives the
|
||||
spell's min/max range — folding in the target's bounding radius — and
|
||||
compares center-to-center distance, so the boundary matches the
|
||||
client exactly, melee and min-range ("too close") spells included.
|
||||
|
||||
Range-only, matching retail: it ignores line of sight, and it does
|
||||
**not** reject wrong-faction targets (a friendly-only heal still
|
||||
returns a range answer against an enemy). Check target validity
|
||||
separately if you need it. Absent tokens (e.g. `"target"` with no
|
||||
target) return `nil`; a genuinely unrecognized token string raises a
|
||||
Lua error, same contract as `UnitHealth("garbage")`.
|
||||
|
||||
### `C_Spell.IsAutoAttackSpell(spellID)`
|
||||
|
||||
Returns `true` if the spell is the melee auto-attack — spell ID
|
||||
@@ -10289,6 +10319,38 @@ which is left untouched).
|
||||
> exactly co-located units) is indistinguishable by value from the miss
|
||||
> placeholder, always branch on `checkedPosition`.
|
||||
|
||||
### `UnitInLineOfSight(unit)`
|
||||
|
||||
Returns `true` if the player has clear line of sight to `unit`, `false`
|
||||
if world geometry (terrain or a building) blocks it, and `nil` when the
|
||||
check can't apply — an absent / unresolvable token, or a non-unit
|
||||
object. A ClassicAPI extension (retail has no such global).
|
||||
|
||||
```lua
|
||||
if UnitInLineOfSight("target") then
|
||||
-- clear shot
|
||||
end
|
||||
```
|
||||
|
||||
Traces a ray between the two units through the client's world-collision
|
||||
geometry (`CWorld::Intersect`, flags `terrain + WMO`). Endpoints are
|
||||
raised by each unit's collision-box height so a foot-to-foot ray doesn't
|
||||
false-block on terrain, and the trace runs in two passes (level look,
|
||||
then look up/down) for height mismatches — the same approach UnitXP_SP3
|
||||
uses, implemented natively so it works with or without that addon (a
|
||||
call routes harmlessly through UnitXP_SP3's hook when present).
|
||||
|
||||
Notes and limits:
|
||||
- **Terrain + buildings (WMO) only** — M2 *doodads* (trees, small props)
|
||||
are not tested (that flag combination crashes in some dungeons). This
|
||||
matches vanilla server-side LoS, which also ignores most doodads.
|
||||
- **Center/eye-line**, not a volume — a sliver of a unit peeking past a
|
||||
corner reads as blocked if the eye-line itself is occluded.
|
||||
- Pairs beyond ~150 yards report `false` (a guard against a
|
||||
long-segment crash in the underlying trace); such units are usually
|
||||
outside the client's object sync window anyway.
|
||||
- `UnitInLineOfSight("player")` is `true` (you always see yourself).
|
||||
|
||||
### `UnitClassBase(unit)`
|
||||
|
||||
Returns `(classFile, classID)` — the locale-independent class
|
||||
|
||||
@@ -951,6 +951,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 +1369,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
|
||||
|
||||
+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;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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", …).
|
||||
//
|
||||
// Backed by the engine's own geometric range core `FUN_006E47B0` — the
|
||||
// same test the action-button range coloring uses:
|
||||
// Script_IsActionInRange (0x004E7550) → FUN_004E56F0 → FUN_006E4440
|
||||
// → FUN_006E47B0
|
||||
// It derives the spell's min/max range (via FUN_006E3480, which folds
|
||||
// in the target's bounding radius), then compares center-to-center
|
||||
// distance, so the boundary matches the client's own "in range"
|
||||
// exactly — melee ranges and min-range (too-close) spells included.
|
||||
//
|
||||
// We call the pure core, not the higher-level FUN_006E4440: that
|
||||
// wrapper also gates on target *type* (friend/foe/etc.) and collapses
|
||||
// "range doesn't apply" into the same truthy return as "in range", so
|
||||
// it can't yield a clean true/false/nil. The core answers range only,
|
||||
// matching retail IsSpellInRange's range-only semantics (it ignores
|
||||
// line of sight). One deliberate consequence: we don't reject
|
||||
// wrong-faction targets — a friendly-only heal still reports a range
|
||||
// answer against an enemy. Callers needing target validity should
|
||||
// check it separately.
|
||||
|
||||
#include "Arg.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::InRange {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets — mirrors
|
||||
// `Spell::HasRange` (kept local to avoid a cross-TU dependency; small
|
||||
// and stable, same layout documented in CLAUDE.md).
|
||||
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
|
||||
constexpr int OFF_SPELLRANGE_MIN = 0x04;
|
||||
constexpr int OFF_SPELLRANGE_MAX = 0x08;
|
||||
|
||||
// True iff the spell's SpellRange.dbc row has a non-zero min or max.
|
||||
// Rangeless spells (self buffs, etc.) have no meaningful "in range"
|
||||
// answer, so we return nil for them — matching retail.
|
||||
bool SpellIDHasRange(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
|
||||
Offsets::VAR_SPELL_RECORD_COUNT,
|
||||
static_cast<uint32_t>(spellID));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
const uint32_t rangeIdx =
|
||||
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
|
||||
if (rangeIdx == 0)
|
||||
return false;
|
||||
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
|
||||
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
|
||||
if (rangeRec == nullptr)
|
||||
return false;
|
||||
const float minRange =
|
||||
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
|
||||
const float maxRange =
|
||||
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
|
||||
return minRange > 0.0f || maxRange > 0.0f;
|
||||
}
|
||||
|
||||
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
|
||||
int spellID, char *outFlag);
|
||||
|
||||
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const char *unit =
|
||||
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
|
||||
// nil when the range check can't apply: unknown/rangeless spell, or
|
||||
// no unit token given.
|
||||
if (spellID <= 0 || unit == nullptr || !SpellIDHasRange(spellID)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void *caster = Unit::Position::ResolveToken("player");
|
||||
void *target = Unit::Position::ResolveToken(unit);
|
||||
|
||||
// Guard positions before handing the objects to the engine core:
|
||||
// FUN_006E47B0 dereferences each GetPosition result without a null
|
||||
// check, and a resolved-but-position-less object (or an absent token
|
||||
// → null) would otherwise crash. A failed read → nil.
|
||||
float casterPos[3];
|
||||
float targetPos[3];
|
||||
if (!Unit::Position::Read(caster, casterPos) ||
|
||||
!Unit::Position::Read(target, targetPos)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char minMaxFlag = 0;
|
||||
auto check =
|
||||
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
|
||||
const char inRange = check(caster, target, spellID, &minMaxFlag);
|
||||
Game::Lua::PushBool(L, inRange != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellInRange",
|
||||
&Script_C_Spell_IsSpellInRange);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::InRange
|
||||
+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
|
||||
Reference in New Issue
Block a user