Compare commits

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

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

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

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

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

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

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

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

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

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

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