Compare commits

...
13 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
Brues 4ba82e59e4 table: reset getn length counter in table.wipe
table.wipe nil'd every key via lua_next + rawset(nil) but left the
Lua 5.0 length counter untouched. In 5.0 a table's table.insert/getn
length lives out of band from its keys (a t.n field, else a weak
sizes[t] entry in the registry's LUA_SIZES table), so it survived the
clear. After a wipe, getn stayed at its old value and subsequent
table.insert appended past the wiped slots -- t[1] stayed nil. The
3.3.5 (Lua 5.1) twipe this was ported from has no getn legacy, which
is why the ported logic looked complete.

Fix: call luaL_setn(L, 1, 0) after the clear loop. Bound the engine's
luaL_setn (FUN_006f4ea0, verified against 5.0.2; located via
table.insert FUN_007fb6b0) as Game::Lua::SetN.

Repro before: table.insert(t,1); table.insert(t,2); table.wipe(t);
table.insert(t,3) -> getn 3, t[1] nil, t[3]=3.
After: getn 2, t[1]=3, t[2]=4. Verified in-game.
2026-07-14 00:37:19 -05:00
Brues b05c15ffa7 input: evict GetMouseButtonClicked so it reads nil outside a handler
Previously g_lastButton persisted as the last click forever. Modern
WoW returns the button only while a mouse handler is running and nil
otherwise (verified in 3.3.5: GetMouseButtonClicked pushes a
frame-manager field [mgr+0x1234] that is set before a mouse handler
dispatches and cleared after, so it is nil when null).

We cannot bracket the handler the same way: WoW dispatches the
frame-script mouse handlers deferred from the WH_GETMESSAGE pump, so
eviction is time-based via the existing per-frame WorldTick. The
button is still captured on each WM button message; EvictTick clears
it after a couple of quiet ticks with nothing held. A held button
keeps it (OnDragStart still reads it) and it survives the whole frame
its click lands in, so multiple addons hooking one OnClick all see it,
avoiding the multi-reader hole a consume-on-read latch would have.

Verified in-game: value matches the GLOBAL_MOUSE_DOWN button arg inside
a handler, and reads nil once clicks settle.
2026-07-13 23:41:33 -05:00
Brues 4daf6b9364 input: add GetMouseButtonClicked()
Returns the button name of the most recent mouse-button message
("LeftButton"/.../"Button5"), or nil before any click. Rides the
existing WH_GETMESSAGE stream in GlobalMouse that already backs
GLOBAL_MOUSE_DOWN/UP and IsMouseButtonDown, so no engine hook is
needed: g_lastButton is captured on every press/release, and the
Script fn pushes it (null tail-jumps to pushnil for the "no click
yet" case).

The hook runs at message-dequeue, synchronously before the engine
dispatches the click to the Lua handler, so the value reflects the
button of the handler currently running (and is readable from nested
helpers where arg1 isn't in scope). Not cleared between events -- no
"handler ended" signal in the 1.12 stream -- so outside a mouse
handler it returns the last-clicked button rather than nil; documented.
2026-07-13 19:02:51 -05:00
Brues 4c6c9deae6 namecache: share the on-disk cache across accounts per server
Move the persistent name cache from the per-account
WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt to a shared
WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt, so alts on
different accounts of the same server pool into one file instead of each
rebuilding their own.

Keyed by the realmlist address (unambiguous server identity — can't collide
across unrelated private servers, the same reason CredStore scopes on it)
plus the realm name. Since this path isn't under Account\, WoW doesn't
create it: EnsureParentDirs makes the tree before saving. On first load with
no shared file yet, a pre-existing per-account cache is migrated in (load
old file, mark dirty so the next save lands in the shared location); the old
file is left untouched.

The PersistentNameCacheEnabled / NameCacheScanEnabled toggles stay
per-account in WTF\Account\<acct>\ClassicAPI.txt — each account opts in
independently; only the cache contents are shared.
2026-07-13 12:52:52 -05:00
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
19 changed files with 1386 additions and 196 deletions
+3 -3
View File
@@ -39,7 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
@@ -54,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -62,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
+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".
+197 -6
View File
@@ -194,6 +194,7 @@ build instructions.
- [`IsLeftAltKeyDown()` / `IsRightAltKeyDown()`](#isleftaltkeydown--isrightaltkeydown)
- [`IsModifierKeyDown()`](#ismodifierkeydown)
- [`IsMouseButtonDown([button])`](#ismousebuttondownbutton)
- [`GetMouseButtonClicked()`](#getmousebuttonclicked)
- [Instance](#instance)
- [`GetInstanceInfo()`](#getinstanceinfo)
@@ -348,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)
@@ -359,6 +361,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 +430,8 @@ build instructions.
- [`UnitIsOtherPlayersPet(unit)`](#unitisotherplayerspetunit)
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
@@ -4294,6 +4300,38 @@ if IsMouseButtonDown("RightButton") then
end
```
### `GetMouseButtonClicked()`
Returns the name of the button responsible for the mouse handler
currently running (`"LeftButton"`, `"RightButton"`, `"MiddleButton"`,
`"Button4"`, `"Button5"`), or `nil` when no click is being handled.
Modern addons call this inside a mouse handler (`OnClick`,
`OnMouseDown`, `OnMouseUp`, `OnDragStart`, …) to learn which button
drove it — an alternative to reading vanilla's `arg1`, and readable
from nested helper functions where `arg1` isn't in scope. The value
is captured by the same `WH_GETMESSAGE` hook behind
`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP`, then evicted a couple of
frames later so — matching the real API — it reads `nil` outside a
handler rather than lingering as the last click forever.
The button stays readable for the whole frame its click lands in (so
multiple addons hooking the same `OnClick` all see it), and while a
button is physically held (so `OnDragStart` reads it). It is a
best-effort replica: WoW dispatches these handlers deferred from the
OS message, so eviction is time-based (per-frame) rather than exactly
bracketed around the handler as on retail. Values can therefore
linger ~2 frames past a click — harmless, since it's only meaningful
inside a mouse handler.
```lua
button:SetScript("OnClick", function()
if GetMouseButtonClicked() == "RightButton" then
-- right-click behavior
end
end)
```
## Instance
### `GetInstanceInfo()`
@@ -7034,12 +7072,18 @@ Three population sources feed the cache when fully enabled:
Storage:
- `WTF\Account\<account>\ClassicAPI.txt` — account-level settings
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`).
- `WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt` —
per-realm cache. Tab-separated text, ~30 bytes/entry,
hand-editable. Shared across all characters on the same
account+realm (a 50-character bank alt's chat scraping doesn't
double the storage cost).
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`). The
opt-in toggles stay per-account.
- `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt` —
per-realm cache, **shared across every account on the same
server**. Keyed by the realmlist address (unambiguous server
identity, can't collide across unrelated private servers) plus the
realm name — so alts on different accounts pool into one file
rather than each rebuilding their own. Tab-separated text,
~30 bytes/entry, hand-editable. On first load with no shared file
yet, a pre-existing per-account cache
(`WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt`) is
migrated in automatically.
### `GetPlayerInfoByGUID(guid)`
@@ -8414,6 +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
@@ -8924,6 +8996,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 +10281,76 @@ mouseover, etc.). For raid members in a different zone or otherwise
not in the engine's sync window, the position virtual returns null
and the function reports `(false, false)`.
### `UnitDistanceSquared(unit)`
Returns `(distanceSquared, checkedPosition)` — the **squared** world
distance (yards²) from the player to `unit`, and a flag indicating
whether both positions were available.
```lua
local distSq, checked = UnitDistanceSquared("target")
if checked and distSq <= 30 * 30 then
-- target is within 30 yards
end
```
| Return | Meaning |
|--------|---------|
| `distanceSquared` | Squared distance player→unit. `0` when `checkedPosition` is `false` (a placeholder — matches retail's "always a number" shape). |
| `checkedPosition` | `true` when both positions were read. `false` when `unit`'s position isn't available (empty `partyN` slot, no target, raid member outside the sync window, etc.). |
The value is **squared** on purpose: nearly all distance logic is a
threshold compare (`distSq <= range * range`) or a nearest-unit sort,
neither of which needs the square root — so retail exposes only the
squared form (added 5.0.4) and never a plain `UnitDistance`. Take
`math.sqrt(distanceSquared)` only when you need a yard number to show a
human.
Reads world positions via the `CGObject::GetPosition` vtable virtual
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
which is left untouched).
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitInLineOfSight(unit)`
Returns `true` if the player has clear line of sight to `unit`, `false`
if world geometry (terrain or a building) blocks it, and `nil` when the
check can't apply — an absent / unresolvable token, or a non-unit
object. A ClassicAPI extension (retail has no such global).
```lua
if UnitInLineOfSight("target") then
-- clear shot
end
```
Traces a ray between the two units through the client's world-collision
geometry (`CWorld::Intersect`, flags `terrain + WMO`). Endpoints are
raised by each unit's collision-box height so a foot-to-foot ray doesn't
false-block on terrain, and the trace runs in two passes (level look,
then look up/down) for height mismatches — the same approach UnitXP_SP3
uses, implemented natively so it works with or without that addon (a
call routes harmlessly through UnitXP_SP3's hook when present).
Notes and limits:
- **Terrain + buildings (WMO) only** — M2 *doodads* (trees, small props)
are not tested (that flag combination crashes in some dungeons). This
matches vanilla server-side LoS, which also ignores most doodads.
- **Center/eye-line**, not a volume — a sliver of a unit peeking past a
corner reads as blocked if the eye-line itself is occluded.
- Pairs beyond ~150 yards report `false` (a guard against a
long-segment crash in the underlying trace); such units are usually
outside the client's object sync window anyway.
- `UnitInLineOfSight("player")` is `true` (you always see yourself).
### `UnitClassBase(unit)`
Returns `(classFile, classID)` — the locale-independent class
+2 -1
View File
@@ -56,7 +56,8 @@ namespace Lua {
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+56
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
@@ -3476,6 +3521,17 @@ enum Offsets {
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
// `n` field exists, else `sizes[t] = n` in the registry's weak
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
// Needed by `table.wipe` to reset the length after clearing keys —
// the length is out-of-band from the keys, so a `lua_next` +
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
+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
+82
View File
@@ -24,6 +24,26 @@
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// `GetMouseButtonClicked()` — the button name of the most recent
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
// when no click is being handled. Modern addons call it inside a
// mouse handler to learn which button drove it; the value is evicted
// shortly after, so — like the real API — it reads nil outside a
// handler rather than returning a stale last-click forever.
//
// Modern WoW backs this with a frame-manager field set right before a
// mouse handler runs and cleared right after (verified in 3.3.5:
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
// field is null). We can't bracket the handler the same way here: the
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
// dispatches the frame-script mouse handlers *deferred* (during the
// frame update, not the message pump). So instead we capture the
// button on the message and evict it from the per-frame `WorldTick`
// once a couple of quiet ticks pass with no button held — the value
// survives the whole frame its click lands in (so every handler,
// including multiple addons hooking one `OnClick`, sees it) then
// clears. A held button keeps it set so `OnDragStart` still reads it.
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -33,6 +53,7 @@
#include "Game.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
#include <cstring>
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
// Name of the button from the most recent mouse-button message, or
// nullptr when no click is being handled. Points at one of
// DecodeButton's string literals (stable for the process lifetime),
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
// caller with no extra branch. Set on each button message; evicted by
// EvictTick (see the header note on GetMouseButtonClicked).
const char *g_lastButton = nullptr;
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
// tell a just-clicked frame from a quiet one. Number of consecutive
// quiet ticks (no activity, nothing held) required before eviction —
// >1 so a handler dispatched a frame after its message still reads
// the button.
bool g_buttonActivity = false;
int g_quietTicks = 0;
constexpr int kQuietTicksToEvict = 2;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
if (name == nullptr)
return;
// Capture for GetMouseButtonClicked on both press and release —
// during an OnClick / OnMouseUp handler the "clicked" button is the
// one just released, same as the press for a normal click.
g_lastButton = name;
g_buttonActivity = true;
g_quietTicks = 0;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
return 1;
}
int __fastcall Script_GetMouseButtonClicked(void *L) {
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
// callers outside a mouse handler see nil, matching the modern API.
Game::Lua::PushString(L, g_lastButton);
return 1;
}
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
// world tick. While a button is held we keep the value (drag handlers
// still read it) and reset the quiet counter. A frame that saw a fresh
// button message consumes its activity flag but doesn't yet evict —
// that one-frame grace lets a handler dispatched the frame after its
// message still read the button. Only after kQuietTicksToEvict ticks
// with nothing held and no activity do we clear, so outside a click
// GetMouseButtonClicked reads nil instead of a stale last-click.
void EvictTick() {
if (g_lastButton == nullptr)
return;
if (g_mouseButtonMask != 0) {
g_quietTicks = 0;
return;
}
if (g_buttonActivity) {
g_buttonActivity = false;
g_quietTicks = 0;
return;
}
if (++g_quietTicks >= kQuietTicksToEvict) {
g_lastButton = nullptr;
g_quietTicks = 0;
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -157,11 +236,14 @@ void InstallHook() {
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
&Script_GetMouseButtonClicked);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
+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;
+117 -22
View File
@@ -23,13 +23,19 @@
// parse callbacks, and the registry handles load/save so neither
// this module nor `Charlist::Order` clobbers the other's lines.
//
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents. v2 format. One entry per line:
// `guidHi guidLo classID raceID sex name`
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents, SHARED across every account on the
// same server. Keyed by realmlist address (unambiguous server
// identity — can't collide across unrelated private servers) plus
// the realm name, rather than by account, so alts on different
// accounts pool into one file. Not under `Account\`, so we create
// the directory tree ourselves. v4 format. One entry per line:
// `name guid classID raceID sex`
// (fields tab-separated). Loaded once the realm name is
// available post-login. Saved when (a) a recent write hasn't
// been flushed AND (b) at least 30 seconds have elapsed since
// the prior save, OR (c) DLL teardown / `Flush()` is called.
// available post-login (migrating a pre-existing per-account file
// in on first load). Saved when (a) a recent write hasn't been
// flushed AND (b) at least 30 seconds have elapsed since the prior
// save, OR (c) DLL teardown / `Flush()` is called.
//
// Population sources:
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
@@ -69,6 +75,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <istream>
#include <ostream>
#include <sstream>
#include <string>
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
// ----- path resolution -----
// realmList CVar value (the realmlist address, e.g.
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
// friendly realm name it can't collide across unrelated private servers,
// so it's the outer key for the shared cache path. Mirrors
// `Security::CredStore`'s realm scoping.
const char *ReadRealmList() {
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
return reinterpret_cast<GetRealmList_t>(
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
}
// Filesystem-safe path component. We create these directories ourselves
// (WoW doesn't), so any character Windows rejects in a path — or that
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
// Deterministic, so a realm always maps to the same folder.
std::string SanitizeComponent(const char *s) {
std::string out;
for (const char *p = s; p != nullptr && *p; ++p) {
const char c = *p;
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
c == '_';
out.push_back(safe ? c : '_');
}
return out;
}
// Shared, server-specific cache path:
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
// Not under Account\, so alts on different accounts of the same server
// share one pool. Empty (defer) until both realmlist + realm are known.
std::string CachePath() {
const char *realmList = ReadRealmList();
if (realmList == nullptr || realmList[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
std::string out = "WTF\\ClassicAPI\\";
out += SanitizeComponent(realmList);
out += '\\';
out += SanitizeComponent(realm);
out += "\\ClassicAPI_NameCache.txt";
return out;
}
// The pre-shared per-account path, used only to migrate an existing
// cache into the shared location on first load. Built exactly as it was
// written (raw account + realm, unsanitized) so it matches the file
// sitting in WoW's own folders.
std::string OldCachePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
@@ -154,6 +211,21 @@ std::string CachePath() {
return out;
}
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
// directories, so a save into a fresh realm folder would silently fail
// without this. CreateDirectory on an existing dir no-ops.
void EnsureParentDirs(const std::string &filePath) {
for (size_t pos = 0;;) {
const size_t slash = filePath.find('\\', pos);
if (slash == std::string::npos)
break; // remainder is the filename
if (slash > 0)
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
pos = slash + 1;
}
}
// ----- account settings (registered with Settings::Account) -----
void SettingsReset() {
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
return changed;
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (!file.is_open())
return; // first run for this realm
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
// it's the primary key; GUID is a peripheral field carried for
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
// into the store. Name first since it's the primary key; GUID is a
// peripheral field carried for GUID-keyed lookups. Variable-width hex
// GUID (no leading zeros). Shared by the normal load and the one-time
// migration from the old per-account file.
void LoadFromStream(std::istream &file) {
std::string line;
while (std::getline(file, line)) {
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
}
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (file.is_open()) {
LoadFromStream(file);
return;
}
// No shared file yet. One-time migration: seed from the old
// per-account file if it exists, then mark dirty so the next save
// writes the merged data into the shared path. Only when the shared
// file is absent — once it exists it's authoritative (a second
// account's old-only entries just repopulate via scan / SMSG).
const std::string oldPath = OldCachePath();
if (oldPath.empty())
return;
std::ifstream oldFile(oldPath);
if (oldFile.is_open()) {
LoadFromStream(oldFile);
g_dirty = true;
}
}
void SaveCache() {
const std::string path = CachePath();
if (path.empty())
return;
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
// the tree first, else the ofstream open below silently fails.
EnsureParentDirs(path);
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
@@ -319,7 +414,7 @@ void SaveCache() {
// sort/scan key when hand-editing. GUID is variable-width
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
// GUID ever lands here.
out << "# ClassicAPI name cache v4 (per-realm)\n";
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
out << "# name\tguid\tclassID\traceID\tsex\n";
for (const auto &kv : g_entries) {
const std::string &name = kv.first;
+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
+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};
+16
View File
@@ -29,6 +29,11 @@ namespace Table::Wipe {
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
// so the pattern ports verbatim.
//
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
// `table.insert`/`getn` length out of band from its keys (see the
// `luaL_setn` note below), so after the key-clear loop we must also
// reset that counter — the 5.1 twipe had no such legacy to clear.
//
// Stack discipline matches 3.3.5 to the call:
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
Game::Lua::RawSet(L, 1); // t[key] = nil
}
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
// `getn` length is stored OUT of band from its keys (in a `t.n`
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
// loop above — which only clears keys — leaves it at its old value.
// Without this, `table.insert` after a wipe appends at
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
// wipe doesn't need this (no getn legacy), which is why the ported
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
// inserts resume at `t[1]`.
Game::Lua::SetN(L, 1, 0);
// The table is still at stack[1] and is the result we return.
Game::Lua::SetTop(L, 1);
return 1;
+86 -16
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);
}
}
+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
+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};