Compare commits

...
4 Commits
Author SHA1 Message Date
Brues 983cc3bdff unit: add UnitDistanceSquared
Backport retail's UnitDistanceSquared(unit) -> (distanceSquared,
checkedPosition): the squared world distance from the player to the unit,
plus a flag for whether both positions were readable. Squared because
almost all distance logic is a threshold compare or nearest-unit sort,
neither of which needs the sqrt — which is why retail never shipped a plain
UnitDistance.

Added to the existing Unit::Range module alongside UnitInRange (both now
share one PlayerToUnitDistSq compute off the CGObject::GetPosition vtable
virtual). Center-to-center, self-contained (reads the position in C — no
SuperWoW dependency, and leaves SuperWoW's own UnitPosition untouched).
Verified in-game to match SuperWoW UnitPosition math to float precision.

On a position miss returns (0, false); no self-quirk, so
UnitDistanceSquared("player") is a legitimate (0, true).
2026-07-13 00:23:58 -05:00
Brues 18ac033381 spell: add GetSpellLevelInfo + GetSpellRequiredTargetLevel
Expose the target-level gate for ranked beneficial auras — e.g. Divine
Spirit Rank 1 (14752) can only be applied to a target level >= 20.

- C_Spell.GetSpellLevelInfo(spellID) -> spellLevel, baseLevel, maxLevel
  (raw Spell.dbc level fields; nil for an unknown spell).
- GetSpellRequiredTargetLevel(spellID) (global, plus C_Spell mirror) ->
  the minimum castable target level (0 = no restriction; nil if unknown).

The computed helper mirrors the server's SpellMgr::SelectAuraRankForLevel:
returns spellLevel - 10 only when the spell is a ranked positive aura
(spellLevel > 10, not passive, has a Rank string, and has an APPLY_AURA
effect on a friendly/party/raid target or an APPLY_AREA_AURA_PARTY effect),
else 0. All fields read directly from the client's Spell.dbc.

The explicit MinTargetLevel / MaxTargetLevel fields the server also checks
are server-only columns (absent from the 1.12 client DBC, whose tail is
SpellFamilyName / SpellFamilyFlags / MaxAffectedTargets), so the spellLevel
rule is the only client-readable target-level mechanism.
2026-07-12 23:28:41 -05:00
Brues 81434f7014 aura: evict phantom cache entries on removal and descriptor reconcile
C_UnitAuras' descriptor-drop fallback could surface a "phantom" aura that
was actually gone — enumerated with a spellId but SetUnitAura rendering an
empty tooltip. Two cases, both fixed:

- Owner cancels a buff (e.g. a flask) while dead: the server keeps
  death-persistent auras through death without changing any field, so death
  itself fires no OnAuraRemoved. Any OnAuraRemoved we do receive for one is
  genuine (cancel/dispel/expiry), so always evict the cache entry — including
  death-persistent auras on dead units. Fixes the phantom for the caster and
  nearby observers (both receive the removal packet).

- A buff cancelled while its owner is out of an observer's range sends no
  packet, so the stale entry lingers. Reconcile on return: when a unit's
  descriptor holds >=1 raw aura it's in view and fully synced, so it's
  authoritative — evict any cached entry whose spell isn't in the array
  (Aura::Data::ReconcileCache -> Aura::Source::EvictAbsent, run at the top of
  the fallback path). Reads raw slots so a flag-hidden aura still counts as
  present; skips an empty array (can't tell out-of-range from buffless).

The only residual is a buff cancelled while the owner is still out of range,
which vanilla can't report until they return.
2026-07-12 17:10:27 -05:00
Brues 22605dad64 tooltip: format compare-item percent stats via PERCENTAGE_STRING
Block/dodge/parry chance and crit/hit are native vanilla percentages, so
their compare-tooltip delta read as a bare number ("-5 Block"). Attach the
percent via the locale-aware PERCENTAGE_STRING global so it renders
"-5% Block" without touching any _SHORT label (correct in every locale).

IsPercentStat marks the percent keys; FormatPercent substitutes the value
into PERCENTAGE_STRING's first conversion slot by hand (never feeds the
locale format to snprintf) with a plain "%" fallback. Flat stats (Defense,
Attack Power, Block Value) stay bare; dropped the now-redundant trailing
" %" from the English fallback labels.
2026-07-12 14:23:54 -05:00
8 changed files with 447 additions and 36 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.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -62,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `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` |
+90
View File
@@ -359,6 +359,8 @@ build instructions.
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
@@ -426,6 +428,7 @@ build instructions.
- [`UnitIsOtherPlayersPet(unit)`](#unitisotherplayerspetunit)
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
@@ -8924,6 +8927,55 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.
### `C_Spell.GetSpellLevelInfo(spellID)`
Returns the raw `Spell.dbc` level fields for a spell:
```
spellLevel, baseLevel, maxLevel = C_Spell.GetSpellLevelInfo(spellID)
```
- `spellLevel` — the rank's effective level (drives the target-level rule
below and per-level scaling).
- `baseLevel` — the spell's base level.
- `maxLevel` — the level at which per-level scaling caps (`0` = no cap).
Returns `nil` for an invalid / unpopulated spellID.
```lua
C_Spell.GetSpellLevelInfo(14752) -- 30, 30, 40 (Divine Spirit Rank 1)
C_Spell.GetSpellLevelInfo(14818) -- 40, 40, 50 (Divine Spirit Rank 2)
```
### `GetSpellRequiredTargetLevel(spellID)`
Also `C_Spell.GetSpellRequiredTargetLevel(spellID)`. Returns the minimum
level a **target** must be for this exact spell (rank) to apply — e.g.
Divine Spirit Rank 1 can only be cast on a target level **20 or higher**.
Returns `0` when the spell has no such restriction, or `nil` for an
invalid spellID.
```lua
GetSpellRequiredTargetLevel(14752) -- 20 (Divine Spirit Rank 1)
GetSpellRequiredTargetLevel(14818) -- 30 (Divine Spirit Rank 2)
GetSpellRequiredTargetLevel(1243) -- 0 (Fortitude Rank 1)
GetSpellRequiredTargetLevel(10060) -- 0 (Power Infusion — single rank)
GetSpellRequiredTargetLevel(133) -- 0 (Fireball — damage, not a buff)
```
Mirrors the server's `SelectAuraRankForLevel` rule: a **ranked beneficial
aura** is castable when `targetLevel + 10 >= spellLevel`, so the minimum
target level is `spellLevel - 10`. The restriction is applied only when the
spell is a ranked positive aura — `spellLevel > 10`, not passive, part of a
rank chain, and applying an aura to a friendly/party/raid target (or an
`APPLY_AREA_AURA_PARTY` effect). Below the minimum, the lowest rank fails
with `SPELL_FAILED_LOWLEVEL`; higher ranks auto-downrank.
> The explicit `MinTargetLevel` / `MaxTargetLevel` spell fields the server
> also enforces are **server-only** columns — they aren't present in the
> 1.12 client's `Spell.dbc`, so the `spellLevel` rule is the only
> client-readable target-level mechanism.
## SpellBook
Functions keyed on the player or pet **spellbook** (slot indices,
@@ -10160,6 +10212,44 @@ mouseover, etc.). For raid members in a different zone or otherwise
not in the engine's sync window, the position virtual returns null
and the function reports `(false, false)`.
### `UnitDistanceSquared(unit)`
Returns `(distanceSquared, checkedPosition)` — the **squared** world
distance (yards²) from the player to `unit`, and a flag indicating
whether both positions were available.
```lua
local distSq, checked = UnitDistanceSquared("target")
if checked and distSq <= 30 * 30 then
-- target is within 30 yards
end
```
| Return | Meaning |
|--------|---------|
| `distanceSquared` | Squared distance player→unit. `0` when `checkedPosition` is `false` (a placeholder — matches retail's "always a number" shape). |
| `checkedPosition` | `true` when both positions were read. `false` when `unit`'s position isn't available (empty `partyN` slot, no target, raid member outside the sync window, etc.). |
The value is **squared** on purpose: nearly all distance logic is a
threshold compare (`distSq <= range * range`) or a nearest-unit sort,
neither of which needs the square root — so retail exposes only the
squared form (added 5.0.4) and never a plain `UnitDistance`. Take
`math.sqrt(distanceSquared)` only when you need a yard number to show a
human.
Reads world positions via the `CGObject::GetPosition` vtable virtual
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
which is left untouched).
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitClassBase(unit)`
Returns `(classFile, classID)` — the locale-independent class
+27
View File
@@ -492,6 +492,31 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
// descriptor holds at least one raw aura the unit is in view and fully synced,
// so it's authoritative — any cached entry for the unit whose spell isn't in
// the array was removed while we couldn't see it (e.g. cancelled out of range,
// so no OnAuraRemoved reached us) and must be dropped before the fallback
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
// but flag-hidden is still legitimately on the unit. Skips when the array is
// empty — that can't be told apart from an out-of-range descriptor drop, and
// reconciling then would wipe still-valid out-of-range entries.
void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
// range. The cache fallback is only trustworthy when the descriptor has been
// wholesale-cleared: the two states it exists for — rogue stealth and party
@@ -559,6 +584,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// The descriptor held `descCount` matches; the caller already found fewer
@@ -589,6 +615,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
+29 -4
View File
@@ -325,10 +325,18 @@ const Game::HookAutoRegister _hookAuraStacks{
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
// stack); `slot` is unused here — we evict by spellId.
// dispelled, was cancelled by its owner, or was replaced by a higher rank
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
// here — we evict by spellId.
//
// Always evict, even for a death-persistent aura (flask, world buff) on a
// dead unit: the server keeps those through death WITHOUT changing any
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
// for one is therefore genuine — e.g. the owner cancelled the flask while
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
// whose SetUnitAura tooltip is empty.
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
@@ -361,6 +369,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
present = true;
break;
}
if (!present)
e.used = false;
}
}
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
return 0;
+12
View File
@@ -50,6 +50,18 @@ struct CachedAura {
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
// written. The classification is recorded from the aura's descriptor slot at
+139
View File
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
}
// ---- Target-level gate for ranked beneficial auras ----------------------
//
// A ranked beneficial aura can't be applied to a target too low for the
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
// is: a ranked positive aura is castable when `targetLevel + 10 >=
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
// downrank. The gate only applies to *ranked* *positive* *auras* — see
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
//
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
// the spellLevel rule is the only client-readable target-level mechanism.)
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
int LocaleIndex() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
// a general "is helpful" set — this is the exact gate the rank rule uses
// (self-cast / pet targets don't count).
bool IsPositiveRankTarget(uint32_t t) {
switch (t) {
case 21: // TARGET_UNIT_FRIEND
case 35: // TARGET_UNIT_PARTY
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
case 57: // TARGET_UNIT_RAID
case 61: // TARGET_UNIT_RAID_AND_CLASS
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
return true;
default:
return false;
}
}
// True if the record carries a localized "Rank N" string — the client's
// marker for a spell in a ranked line (single-rank spells leave it empty).
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
bool HasRankString(const uint8_t *record) {
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + LocaleIndex() * 4);
return rank != nullptr && *rank != '\0';
}
// Minimum target level required to apply this exact spell (rank), or 0 if
// the spell isn't subject to the ranked-positive-aura target-level rule.
int RequiredTargetLevel(const uint8_t *record) {
const int spellLevel =
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
return 0;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & SPELL_ATTR_PASSIVE)
return 0;
if (!HasRankString(record)) // single-rank spell → not gated
return 0;
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
auto *targetA =
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
bool positiveAura = false;
for (int i = 0; i < 3; ++i) {
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
IsPositiveRankTarget(targetA[i])) ||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
positiveAura = true;
break;
}
}
if (!positiveAura)
return 0;
return spellLevel - 10;
}
int ArgSpellID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
return static_cast<int>(Game::Lua::ToNumber(L, 1));
}
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
// spellID.
int __fastcall Script_GetSpellLevelInfo(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
return 3;
}
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
// be for this spell (rank) to apply (0 = no restriction). nil for an
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
return 1;
}
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
// at which a spell becomes available (the `BaseLevel` field in
// Spell.dbc). Direct read off the record — no class/race filtering,
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
&Script_GetSpellLevelLearned);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
&Script_GetCurrentLevelSpells);
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
&Script_GetSpellLevelInfo);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+86 -16
View File
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
// English fallback labels, used when the client doesn't define the
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
// mirrors pfUI's eqcompare so the deltas read consistently on this
// client. Percent stats keep a "%" suffix since the values are native
// vanilla percentages.
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
// "%" to the value ("-5% Block"), so the unit is correct regardless of
// which label (Blizzard `_SHORT` or this fallback) is used.
struct KeyLabel {
const char *key;
const char *label;
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
{"RESISTANCE6_NAME", "Arcane Resistance"},
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
{"ITEM_MOD_PARRY_RATING", "Parry %"},
{"ITEM_MOD_BLOCK_RATING", "Block %"},
{"ITEM_MOD_DODGE_RATING", "Dodge"},
{"ITEM_MOD_PARRY_RATING", "Parry"},
{"ITEM_MOD_BLOCK_RATING", "Block"},
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
{"ITEM_DELTA_DESCRIPTION",
"If you replace this item, the following stat changes will occur:"},
};
// Percent-valued stat keys: the block/dodge/parry *chances* and the
// crit/hit percentages. Vanilla stores these as native percentages (a
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
// Block". The "%" is attached to the NUMBER rather than the label, so it
// stays correct in every locale and requires no `_SHORT` change.
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
bool IsPercentStat(const char *key) {
static const char *const kPercent[] = {
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
"ITEM_MOD_BLOCK_RATING",
};
for (const char *k : kPercent)
if (std::strcmp(k, key) == 0)
return true;
return false;
}
const char *FallbackLabel(const char *key) {
for (const auto &e : kFallbackLabels)
if (std::strcmp(e.key, key) == 0)
@@ -199,22 +221,63 @@ bool EndsWithShort(const char *key) {
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
}
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
// string. Direct globals-table access, no pcall. Returns whether it took.
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
// globals-table access, no pcall. Returns whether a non-empty string was
// copied into `out`.
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
const int saved = Game::Lua::GetTop(L);
Game::Lua::PushString(L, key);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
? Game::Lua::ToString(L, -1)
: nullptr;
const bool ok = (g != nullptr && *g != '\0' && !HasFormatSpecifier(g));
const bool ok = (g != nullptr && *g != '\0');
if (ok)
std::snprintf(out, outSize, "%s", g);
Game::Lua::SetTop(L, saved);
return ok;
}
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
// string. Returns whether it took.
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
char tmp[192];
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
return false;
std::snprintf(out, outSize, "%s", tmp);
return true;
}
// Formats a signed value token (e.g. "-5") as a localized percentage via
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
// format is NEVER passed to snprintf — we substitute `num` into its first
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
// hostile or unexpected format can't misread the argument list. Falls back
// to a plain "%" suffix when the global is absent.
void FormatPercent(void *L, const char *num, char *out, int outSize) {
char fmt[64];
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
std::snprintf(out, outSize, "%s%%", num);
return;
}
int o = 0;
bool inserted = false;
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
if (p[0] == '%' && p[1] == '%') { // literal percent
out[o++] = '%';
++p;
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
out[o++] = *q;
++p; // consume the conversion letter (s / d)
inserted = true;
} else {
out[o++] = *p;
}
}
out[o] = '\0';
}
// Resolve a display label for a stat `key` into `out`, respecting
// localization. The rating/regen tokens GetItemStats keys on
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
@@ -285,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
if (v == 0)
continue;
LabelFor(L, acc[i].key, label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
char num[48];
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
if (IsPercentStat(acc[i].key)) {
char pct[64];
FormatPercent(L, num, pct, sizeof(pct));
std::snprintf(num, sizeof(num), "%s", pct);
}
std::snprintf(line, sizeof(line), "%s %s", num, label);
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
}
}
+62 -14
View File
@@ -8,11 +8,21 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check, modern's `UnitInRange` shape. Vanilla 1.12 doesn't
// ship the function but has all the underlying machinery — the
// same `GetPosition` virtual (vtable slot 5) `CheckInteractDistance`
// uses, applied with a fixed 40-yard threshold.
// Player↔unit proximity, modern's shape. Vanilla 1.12 ships neither
// function but has all the underlying machinery — the object's
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
//
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check (fixed threshold on the squared distance).
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
// the raw squared world distance player→unit. Squared because
// nearly every consumer compares against a threshold (`distSq <=
// range*range`) or ranks by nearest, neither of which needs the
// sqrt; that's why retail exposes only the squared form (added
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
// but self-contained (reads the position in C, no sibling-DLL
// dependency, no clash with SuperWoW's own `UnitPosition`).
#include "Game.h"
#include "unit/Position.h"
@@ -26,6 +36,24 @@ namespace {
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
// Squared world distance from the player to `token`'s unit. Returns true
// and writes *outSq on success; false when either position is unavailable
// (unresolved / absent token, or an object with no known position yet —
// e.g. a party member outside the client's sync range). This false is
// exactly the `checkedPosition = false` case retail flags.
bool PlayerToUnitDistSq(const char *token, float *outSq) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos))
return false;
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
*outSq = dx * dx + dy * dy + dz * dz;
return true;
}
int __fastcall Script_UnitInRange(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
@@ -44,27 +72,47 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos)) {
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
const float distSq = dx * dx + dy * dy + dz * dz;
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
Game::Lua::PushBool(L, true);
return 2;
}
// `UnitDistanceSquared("unit") → (distanceSquared, checkedPosition)`.
// Unlike UnitInRange there's no self-quirk: `UnitDistanceSquared("player")`
// is a legitimate `(0, true)`. On a position miss returns `(0, false)` —
// matching retail's "always a number" shape; consumers must branch on
// `checkedPosition`, since a real `0` (self, or exactly co-located units)
// is indistinguishable from the miss placeholder by value alone.
int __fastcall Script_UnitDistanceSquared(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitDistanceSquared(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushBool(L, false);
return 2;
}
Game::Lua::PushNumber(L, static_cast<double>(distSq));
Game::Lua::PushBool(L, true);
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitInRange", &Script_UnitInRange);
Game::Lua::RegisterGlobalFunction("UnitDistanceSquared",
&Script_UnitDistanceSquared);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};