Compare commits

..
7 Commits
Author SHA1 Message Date
Brues 46597f6587 unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.

Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.

Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
2026-07-30 00:45:00 -05:00
Brues 4e1c1863e9 debugtools: add Refresh button to TableInspector
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.

Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
2026-07-29 23:58:01 -05:00
Brues a4c57614bc debugtool: Use MouseIsOver global for fallback
WIthout ClassicAPI this will fallback to the 1.12.1 implementention of MouseIsOver
2026-07-29 13:14:13 -05:00
Brues e1a9d9b5c4 docs: document IsIndoors / IsOutdoors
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
2026-07-29 11:04:45 -05:00
Brues cd280ac307 state: add IsIndoors / IsOutdoors globals
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.

The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.

Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
2026-07-29 11:01:59 -05:00
Brues 564495248c item: apply random suffix in by-string GetItemNameByID / GetItemInfo
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.

Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.

GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).

Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
2026-07-28 16:33:50 -05:00
Brues 1f3f3cb9f6 aura: infer server-side DoT-duration changes from the triggering cast
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.

Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).

Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.

Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
  Conflagrate  -> reduce Immolate 3s
  Molten Blast -> refresh Flame Shock
  Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
    Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
    Turtle reworked it, so stock DBC proc masks don't apply -- match by
    school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).

Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
2026-07-28 16:14:45 -05:00
18 changed files with 824 additions and 22 deletions
+1
View File
@@ -43,3 +43,4 @@ Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
@@ -0,0 +1,70 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
-- off that trigger.
--
-- A trigger is matched by exact spellID (stable across ranks -- these are the
-- IDs the server binds its spell script to); the affected aura by
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
-- trigger is a whole class of spells rather than a named ability,
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
-- index instead (see Shadow Weaving below).
--
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
-- server-side probability roll on Ferocious Bite. The client can't know the
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
-- register it yourself, e.g. (DRUID = 7):
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
-- removal path, so the reduce rule below is harmless there and correct on
-- Turtle (where Immolate keeps ticking with 3s shaved off).
if TURTLE_WOW_VERSION then
local WARLOCK, SHAMAN = 5, 11
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
for i = 1, table.getn(CONFLAGRATE) do
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
end
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
for i = 1, table.getn(MOLTEN_BLAST) do
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
end
end
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
end
end)
+1 -1
View File
@@ -547,7 +547,7 @@ function EventTraceFrame_Update()
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
button:UnlockHighlight();
end
if (button:IsMouseOver()) then
if MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+2 -2
View File
@@ -61,7 +61,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [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.GetSpellEffectMechanics`, `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`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `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` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
@@ -71,7 +71,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `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` |
| [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.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `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` |
+150
View File
@@ -129,6 +129,7 @@ build instructions.
- [`QUEST_REMOVED` event](#quest_removed-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)](#update_mouseover_unit-event-loss-fire-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
- [Expansion](#expansion)
@@ -459,6 +460,8 @@ build instructions.
- [`IsStealthed()`](#isstealthed)
- [`IsFalling()`](#isfalling)
- [`IsSwimming()`](#isswimming)
- [`IsIndoors()`](#isindoors)
- [`IsOutdoors()`](#isoutdoors)
- [`IsAssistingRitual()`](#isassistingritual)
- [`IsInGroup()`](#isingroup)
- [`IsInRaid()`](#isinraid)
@@ -553,6 +556,7 @@ build instructions.
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
- [`C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`](#c_unitaurasregistercombodurationspellid-baseseconds-maxseconds)
- [`C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`](#c_unitaurasregisterauradurationmodifiertriggerspellid-affectedfamily-affectedfamilyflags-affectedicon-op--valueseconds)
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
@@ -3120,6 +3124,60 @@ byte reads (cheap) and naturally handles the case where one packet
carries multiple rep updates whose threshold crossings ripple AT_WAR
state across unrelated slots.
### `UPDATE_MOUSEOVER_UNIT` event (loss-fire fix)
`UPDATE_MOUSEOVER_UNIT` exists in the vanilla event table and fires when a
mouseover unit is **gained**, but the 1.12 engine **never fires it on
loss** — moving the cursor off a unit clears the mouseover and signals
nothing. Modern WoW fires it for both gain and loss, so addons observing
`UnitExists("mouseover")` can react when it becomes false. We restore the
loss fire.
Payload is unchanged (none in vanilla — same as retail): the event
carries no args; handlers read `UnitExists("mouseover")`, which is
`nil`/false at fire time on the loss path.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
f:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" then
if UnitExists("mouseover") then
-- gained (or changed to) a mouseover unit
else
-- mouseover unit lost (the vanilla gap this fills)
end
end
end)
```
**Transitions and who fires them:**
| From | To | Fires? | Source |
|------|----|:------:|--------|
| unit | nothing | yes | ClassicAPI (silent in stock 1.12) |
| unit | gameobject | yes | ClassicAPI (mouseover unit genuinely lost) |
| gameobject | nothing | no | — (never fired a gain; not a unit) |
| anything | unit | yes | engine's own inline fire (gain / unit→unit) |
**Implementation notes**
The mouseover GUID lives in two globals written **only** by the engine's
mouseover set/clear chokepoint (`0x00492890`), so every gain / loss /
change flows through it — on a unit gain it fires `UPDATE_MOUSEOVER_UNIT`
itself, on loss it clears the GUID silently. We co-hook that function:
resolve whether the mouseover is a *unit* before and after the original,
and fire on the unit→non-unit transition. Gating on unit *type* (not just
"GUID present → absent") is load-bearing — the same GUID slot also holds
gameobjects and items, which never fire a gain, so the type check is what
keeps moving off a herb node or chest from spuriously firing. The
`!hasUnit` half prevents a double-fire on any →unit transition the engine
already covered.
The target is change-gated by its callers (if it ran per-frame the gain
event would spam while hovering), so it's a cool hook — no per-frame
overhead.
### `UPDATE_SHAPESHIFT_FORM` event
Fires whenever the local player's shapeshift form changes — entering
@@ -11099,6 +11157,37 @@ if IsSwimming() then
end
```
### `IsIndoors()`
Returns `1` if the local player is under a WMO roof — inside a building,
cave, or instance interior — and `nil` otherwise. `nil` before the player
object exists (pre-world).
Recomputed live from the engine's WMO geometry query, so it flips as you
cross an interior threshold rather than tracking the current zone/area —
you can be indoors and outdoors within the same subzone. Returns `1`/`nil`
(not `true`/`false`) to match the historical retail contract and
SuperWoW 2.2, so both `if IsIndoors()` and `IsIndoors() == 1` work.
```lua
if IsIndoors() then
-- suppress a mount cast that would just fail
end
```
### `IsOutdoors()`
Returns `1` if the local player is outdoors (open sky, or simply not
inside a WMO interior) and `nil` otherwise. Exact complement of
[`IsIndoors()`](#isindoors) for a resolvable player; both return `nil`
pre-world.
```lua
if IsOutdoors() then
CastSpellByName("Mount Up")
end
```
### `IsAssistingRitual()`
Returns `true` if the local player is currently clicking a warlock
@@ -13174,6 +13263,67 @@ is the escape hatch for *other* custom servers with the same class of
gap — call it from your own config or addon; a registered override
beats both the DBC and the built-in values.
### `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`
ClassicAPI extension for **server-side DoT-duration changes on another
unit that the client is never told about**. Returns `true` on success.
`expirationTime` for a debuff on another unit is computed from the cast
packet (`SMSG_SPELL_GO`) at cast time. When the server later changes that
debuff's remaining duration — a refresh, or a partial consume — 1.12 sends
**no** packet an observing caster can see (verified in the server source:
the change runs through `SetAuraDuration`/`RefreshHolder`, and the only
duration packet is self-scoped to the aura-bearer; on a mob it isn't even
built). So the cached `expirationTime` would go stale.
The client *does* always see the **triggering** cast, though. Register a
rule and ClassicAPI mirrors the server's edit on the cached aura when that
trigger lands:
| Arg | Meaning |
|-----|---------|
| `triggerSpellID` | The spell whose cast triggers the change (match one rank per call — use the exact IDs the server binds). |
| `affectedFamily` | `SpellFamilyName` of the affected aura (e.g. `5` warlock, `11` shaman, `7` druid). |
| `affectedFamilyFlags` | A `SpellFamilyFlags` bitmask; the affected aura matches if it overlaps. Family + flag is rank-proof (covers every rank at once). |
| `affectedIcon` | `SpellIconID` the affected aura must have, or `0` to match any. |
| `op` | `"refresh"` (reset to full duration), `"reduce"` (subtract `valueSeconds`, removing the aura if it would go non-positive), `"set"` (to `valueSeconds`), `"remove"`. |
| `valueSeconds` | Amount for `reduce`/`set`; ignored otherwise. |
The rule only fires for the aura **cast by the same unit** as the trigger
(these mechanics act on the caster's own DoT), and misses are excluded for
free (a missed trigger isn't in the packet's hit list).
**`C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])`**
is the same, but matches the *trigger* by `SpellFamilyName` + school index
(`0` physical … `2` fire, `5` shadow, `6` arcane; `< 0` = any) instead of an
exact spellID — one rule covers a whole class of spells (every rank, plus
server-added ones). Use it when the trigger is a category rather than a named
ability.
`!!!ClassicAPI` ships a default Turtle ruleset
([Util/AuraDurationModifiers.lua](../AddOns/!!!ClassicAPI/Util/AuraDurationModifiers.lua)):
Conflagrate shaves 3s off the caster's Immolate, Molten Blast refreshes the
caster's Flame Shock, and — for a 5/5 Shadow Weaving priest (talent `15334`,
where the proc is 100%) — any shadow-school priest cast refreshes the target's
Shadow Vulnerability (`15258`). Deliberately excluded: probabilistic effects
(Carnage's roll-gated Rip/Rake refresh, and Shadow Weaving below 5/5) — the
client can't see the server's roll, so inferring them would show wrong timers.
Two accepted best-effort caveats on the Shadow Weaving rule: DoT *ticks*
(SW:P / Devouring Plague) also refresh it server-side but emit no cast packet,
so a pure DoT-only phase under-counts slightly; and a full-consume Conflagrate
variant on other servers *removes* Immolate, which ClassicAPI already evicts
via the normal removal path (so the reduce rule is harmless there).
```lua
-- Flags are hex; Lua 5.0 has no 0x literals, so use tonumber(hex, 16).
-- Conflagrate (Rank 4) shaves 3s off the caster's Immolate (warlock, flag 0x4):
C_UnitAuras.RegisterAuraDurationModifier(18932, 5, tonumber("4", 16), 0, "reduce", 3)
-- Molten Blast refreshes the caster's Flame Shock (shaman, flag 0x10000000):
C_UnitAuras.RegisterAuraDurationModifier(36916, 11, tonumber("10000000", 16), 0, "refresh")
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability (15258):
C_UnitAuras.RegisterAuraDurationModifierByTrigger(6, 5, 6, tonumber("4000000", 16), 9, "refresh")
```
### `C_UnitAuras.GetUnitAuras(unit [, filter])`
Returns a numerically-indexed array of `AuraData` tables for every
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+53
View File
@@ -316,6 +316,16 @@ enum Offsets {
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Inner name formatter behind FUN_ITEM_BUILD_INSTANCE_NAME, callable
// WITHOUT a CGItem: `__fastcall(char *out /*ecx*/, uint outSize /*edx*/,
// uint32 itemID, int suffixID)`. Fetches the ItemStats record for itemID
// and, when suffixID is a valid ItemRandomProperties row with a localized
// suffix name (record +0x1c + locale*4), formats base + suffix via
// ITEM_SUFFIX_TEMPLATE; otherwise the base name. Lets the by-STRING item
// paths (GetItemNameByID, C_Item.GetItemInfo) apply an item link's random
// suffix without a live item instance.
FUN_ITEM_BUILD_NAME_FROM_ID = 0x005D8B00,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -544,6 +554,31 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
// these (see the token-dispatch list above). Written ONLY by
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
// `Unit::Mouseover` to detect the loss transition.
VAR_MOUSEOVER_GUID_LO = 0x00B4E2C8,
VAR_MOUSEOVER_GUID_HI = 0x00B4E2CC,
// The engine's single mouseover set/clear chokepoint —
// `__stdcall(guidLo, guidHi, arg3, arg4)` (early returns are all
// `ret 0x10`, and the caller pushes 4 without cleaning). It is the
// ONLY writer of `VAR_MOUSEOVER_GUID_*`, so every mouseover
// gain/loss/change flows through it. On a unit gain it fires
// `UPDATE_MOUSEOVER_UNIT` itself (via `FUN_FIRE_EVENT_NO_ARGS(0x150)`
// in the friendly/hostile-unit tooltip case); on loss it clears the
// GUID but fires nothing — the gap `Unit::Mouseover` closes.
// Change-gated by its callers (if it ran per-frame the gain fire
// would spam every frame while hovering, which vanilla doesn't), so
// it's a cool hook target.
FUN_SET_MOUSEOVER_UNIT = 0x00492890,
// Event ID of `UPDATE_MOUSEOVER_UNIT` (name-table slot
// `0x00BE16D8` → `(0x16D8 - 0x1198) / 4`). Fired with no args via
// `FUN_FIRE_EVENT_NO_ARGS`; Lua handlers read `UnitExists("mouseover")`.
EVENT_UPDATE_MOUSEOVER_UNIT = 0x150,
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
// — the function ends with `ret 0xc`, so the callee pops the
@@ -4704,6 +4739,23 @@ enum Offsets {
// `castGameObject != 0`).
OFF_CGPLAYER_CAST_GAMEOBJECT_PTR = 0xB4,
// Indoor/outdoor query for a unit — backs `IsIndoors()` / `IsOutdoors()`
// (`unit/State.cpp`). `__fastcall(unit) -> uint` where **nonzero =
// outdoors**, 0 = indoors. A 2-instruction thunk: reads the unit's
// world/collision-context pointer at `unit + 0xE0` and tail-jumps to the
// WMO query `FUN_006706f0`. That query is fully null-safe — null context
// or a unit not inside a WMO group returns 1 (outdoors); otherwise it
// resolves the current WMO group via `FUN_0069d4f0` and returns bit 15
// (`0x8000`) of the group's runtime flags word (the OUTDOOR bit).
//
// Derived from SuperWoW 2.2's `IsIndoors` handler (which does exactly
// `unit = ObjMgr(0x10, PlayerGUID()); push (query(unit)==0)`), then
// confirmed against the vanilla binary. Vanilla ships no Lua binding for
// this; the 3.3.5 client's `IsIndoors` (`0x00612300`) uses the identical
// shape (`unit + 0xB8` → WMO group flag test), so the semantics are
// cross-checked across both clients.
FUN_UNIT_IS_OUTDOORS = 0x00612cc0,
// No `MOVEFLAG_FLYING` constant intentionally. Vanilla 1.12
// doesn't flip a flying bit during taxi flights (verified
// empirically: `IsFlying()` checking 0x01000000 stayed false
@@ -5417,6 +5469,7 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ICON_ID = 0x1D4, // u32 SpellIconID (→ SpellIcon.dbc)
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
+263
View File
@@ -27,6 +27,7 @@
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace Aura::Source {
@@ -234,6 +235,263 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
//
// Some server mechanics change a DoT's remaining time on another unit with
// NO client-visible signal — verified in tortoise-wow: the change goes
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules come from Lua
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
// lives in the addon, not the DLL. Turtle examples:
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched by exact spellID (from the server's script binding,
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
// deliberately NOT shipped as defaults — the client can't know the server's
// roll outcome, so inferring them would show wrong timers.
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
struct DurationMod {
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
uint32_t affectedFamily; // SpellFamilyName of the affected aura
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
int32_t op;
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
};
constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
return false;
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if ((flags & m.affectedMask) == 0)
return false;
if (m.affectedIcon != 0 &&
*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
return false;
return true;
}
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
// by the cast spell's SpellFamilyName + school index — one rule then covers
// every rank / server-added spell of a class's school (e.g. any priest
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
if (m.triggerSpellId != 0)
return m.triggerSpellId == triggerSpellId;
if (triggerRec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
return false;
if (m.triggerSchool >= 0 &&
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
return false;
return true;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
case MOD_REFRESH:
if (e.durationMs > 0)
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
else
e.used = false; // shaved to/past now → server removes it
}
break;
case MOD_REMOVE:
e.used = false;
break;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
// own, so they'd otherwise be dropped.
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
const uint64_t *targets, int numTargets) {
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
return;
const uint8_t *triggerRec =
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
const uint32_t now = NowMs();
for (int r = 0; r < g_modCount; ++r) {
const DurationMod &m = g_mods[r];
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
// but PROC-applied ones (Shadow Weaving) enter the cache via the
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
// caster), so accept those too and attribute them to this trigger
// caster (also gives them a sourceUnit).
if (e.casterGuid != 0 && e.casterGuid != caster)
continue;
if (!AffectedMatches(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
continue;
if (e.casterGuid == 0)
e.casterGuid = caster;
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
}
}
}
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
DurationMod &m = g_mods[i];
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
m.op = op;
m.valueMs = valueMs;
return true;
}
}
if (g_modCount >= kMaxMods)
return false;
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
affectedFamily, affectedMask, affectedIcon, op, valueMs};
return true;
}
int OpFromString(const char *s) {
if (s == nullptr)
return -1;
if (_stricmp(s, "refresh") == 0)
return MOD_REFRESH;
if (_stricmp(s, "reduce") == 0)
return MOD_REDUCE;
if (_stricmp(s, "set") == 0)
return MOD_SET;
if (_stricmp(s, "remove") == 0)
return MOD_REMOVE;
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
// (trigger, family, flags, icon) tuple replaces its op/value.
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsString(L, 5)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
"valueSeconds])");
return 0;
}
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
const int op = OpFromString(Game::Lua::ToString(L, 5));
const int32_t valueMs =
Game::Lua::IsNumber(L, 6)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
: 0;
if (op < 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, family, mask,
icon, op, valueMs));
return 1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
// by SpellFamilyName + school index instead of an exact spellID — one rule
// covers every rank / server-added spell of a class's school. triggerSchool
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
// refresh the target's Shadow Vulnerability.
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
"affectedIcon, op[, valueSeconds])");
return 0;
}
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
const int op = OpFromString(Game::Lua::ToString(L, 6));
const int32_t valueMs =
Game::Lua::IsNumber(L, 7)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
: 0;
if (op < 0 || tfamily == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
family, mask, icon, op, valueMs));
return 1;
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
&Script_RegisterAuraDurationModifier);
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
@@ -328,6 +586,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (caster == Unit::Identity::PlayerGuid())
Totem::Tracker::OnPlayerSpellGo(spellId);
// Mirror server-side duration edits the client is never told about
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
+13 -2
View File
@@ -21,7 +21,7 @@
namespace Item::Arg {
Resolved Resolve(void *L, int idx) {
Resolved out{0, nullptr};
Resolved out{0, 0, nullptr};
if (Game::Lua::IsNumber(L, idx)) {
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
return out;
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
return out;
}
if (const char *m = std::strstr(s, "item:")) {
out.itemID = std::atoi(m + 5);
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
out.suffix = std::atoi(p + 1);
break;
}
}
return out;
}
// Plain string — try numeric, otherwise expose as a name for
+1
View File
@@ -22,6 +22,7 @@ namespace Item::Arg {
// can ignore `name` and treat `itemID == 0` as "no usable input".
struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
};
+15 -4
View File
@@ -259,7 +259,8 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Item::Arg::Resolved arg = Item::Arg::Resolve(L, 1);
const int itemID = arg.itemID;
if (itemID <= 0)
return 0;
@@ -288,14 +289,24 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
// Apply the link's random suffix ("... of the Owl") to both the name and
// the reconstructed link when the input carried one (arg.suffix); with no
// suffix these reduce to the base name / plain `item:N:0:0:0` link.
char suffixedName[128];
const char *displayName =
Item::Link::NameFromIDSuffix(static_cast<uint32_t>(itemID), arg.suffix,
suffixedName, sizeof(suffixedName))
? suffixedName
: name;
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
const bool haveLink = Item::Link::BasicFromIDSuffix(static_cast<uint32_t>(itemID),
arg.suffix, link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
Game::Lua::PushString(L, PushedOrEmpty(displayName)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
+31 -7
View File
@@ -30,6 +30,10 @@ using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Inner name builder, no CGItem needed: (out, outSize, itemID, suffixID).
using BuildNameFromID_t = void(__fastcall *)(char *out, unsigned outSize,
uint32_t itemID, int suffixID);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -63,25 +67,45 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildNameFromID_t>(
Offsets::FUN_ITEM_BUILD_NAME_FROM_ID);
fn(out, static_cast<unsigned>(outSize), itemID, suffixID);
return out[0] != '\0';
}
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr)
return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return false;
char name[128];
if (!NameFromIDSuffix(itemID, suffixID, name, sizeof(name))) {
// Suffix builder yielded nothing (e.g. suffix row missing) — fall back
// to the base record name so we at least produce a valid link.
const char *base = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (base == nullptr || *base == '\0')
return false;
std::snprintf(name, sizeof(name), "%s", base);
}
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
"%s|Hitem:%u:0:%d:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffixID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
return BasicFromIDSuffix(itemID, 0, out, outSize);
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+14
View File
@@ -51,4 +51,18 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
// Writes the suffix-decorated display name for `(itemID, suffixID)` into
// `out` WITHOUT a live item instance — the by-string analog of
// NameFromCGItem. `suffixID` is the 3rd `item:` field (random property /
// suffix); 0 = base name. Applies the "... of the Owl" suffix via the
// engine's inner builder (FUN_ITEM_BUILD_NAME_FROM_ID) when the item is
// cached and the suffix is a valid ItemRandomProperties row. Returns false
// (out empty) if the item isn't cached or has no name — warm and retry.
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
// Like BasicFromItemID but preserves the random `suffixID` in both the
// `item:` payload (`item:N:0:suffix:0`) and the bracketed, suffix-decorated
// name. `suffixID` 0 reduces exactly to BasicFromItemID.
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
} // namespace Item::Link
+20 -1
View File
@@ -94,8 +94,27 @@ static int __fastcall Script_C_Item_GetItemName(void *L) {
// for `itemID`, firing a background cache fill on miss. Accepts the
// same input shapes as `GetItemInfoInstant` (number, `"item:NNN..."`
// string, or any string containing a parseable `item:NN` chunk).
//
// When the input is an item link carrying a random suffix
// (`item:12022:0:769:0`), the returned name is suffix-decorated
// ("Iridium Chain of the Owl") — the 3rd `item:` field is parsed and
// applied via the engine's own name builder, so it matches the global
// `GetItemInfo`'s name. A bare itemID (number or `item:N`) has no suffix
// and yields the base name.
static int __fastcall Script_C_Item_GetItemNameByID(void *L) {
return PushNameForItemID(L, Item::Arg::ResolveItemID(L, 1));
const Item::Arg::Resolved r = Item::Arg::Resolve(L, 1);
if (r.itemID <= 0)
return 0;
char buf[128];
if (Item::Link::NameFromIDSuffix(static_cast<uint32_t>(r.itemID), r.suffix,
buf, sizeof(buf))) {
Game::Lua::PushString(L, buf);
return 1;
}
// Not cached yet — fire a background fill and return nil (same miss
// behavior as the base-name path).
Item::Data::WarmCache(static_cast<uint32_t>(r.itemID));
return 0;
}
static void RegisterLuaFunctions() {
+115
View File
@@ -0,0 +1,115 @@
// 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/>.
// UPDATE_MOUSEOVER_UNIT loss-fire polyfill.
//
// Vanilla fires `UPDATE_MOUSEOVER_UNIT` only when a mouseover is *gained*
// (the engine's `FUN_SET_MOUSEOVER_UNIT` fires it inline in the
// friendly/hostile-unit tooltip case). When the cursor leaves the unit,
// the engine clears the mouseover GUID but fires nothing — so an addon
// that reacted to `UnitExists("mouseover")` becoming true never gets told
// when it becomes false. Retail fires the event for both gain and loss.
//
// We close the gap by co-hooking the single set/clear chokepoint (it is
// the ONLY writer of the mouseover GUID globals, so every gain / loss /
// change flows through it): resolve whether the mouseover was a *unit*
// before the original, then after, and fire `UPDATE_MOUSEOVER_UNIT` when
// it went from a unit to a non-unit.
//
// The unit-vs-non-unit test matters: the mouseover GUID also holds
// gameobjects (and items), but the engine only fires the *gain* event for
// units — the gameobject/item cases never fire it. So a naive
// "GUID present -> absent" test spuriously fires when you move off a
// gameobject that never fired a gain. Gating on "old was a unit, new
// isn't" fixes that while still matching retail's other cases:
//
// * unit -> nothing : fire (retail fires)
// * unit -> gameobject : fire (retail fires — mouseover unit lost)
// * gameobject -> nothing: no (bug fixed; no gain ever fired)
// * X -> unit : engine already fired inline; we don't (new is
// a unit, so our `!newIsUnit` guard is false —
// no double-fire on gain or unit->unit change)
//
// The event carries no arguments in vanilla (same as retail); handlers
// read `UnitExists("mouseover")` — which is false at fire time on the loss
// path, exactly matching retail semantics.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Mouseover {
namespace {
// `FUN_SET_MOUSEOVER_UNIT` is `__stdcall(guidLo, guidHi, arg3, arg4)` —
// see Offsets.h. We don't use the args; they pass straight through.
using SetMouseover_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4);
using FireEventNoArgs_t = void(__fastcall *)(int eventID);
// `FUN_OBJECT_RESOLVE_BY_GUID(typeMask, debugName, guidLo, guidHi, prio)`
// — keeps the object only if its type flags intersect `typeMask`, so the
// unit|player mask returns non-null only for a live unit / player / pet /
// MC'd creature (never a gameobject or item). Same primitive
// `Unit::Flags::ResolveUnitOrPlayerByGuid` uses.
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
// True iff the current mouseover GUID resolves to a unit (not a
// gameobject / item / nothing).
bool MouseoverIsUnit() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_LO));
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_HI));
if ((lo | hi) == 0)
return false;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return fn(kUnitOrPlayerMask, "UPDATE_MOUSEOVER_UNIT", lo, hi, 0) != nullptr;
}
SetMouseover_t SetMouseover_o = nullptr;
void __stdcall SetMouseover_h(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4) {
const bool hadUnit = MouseoverIsUnit();
SetMouseover_o(guidLo, guidHi, arg3, arg4);
const bool hasUnit = MouseoverIsUnit();
// Only the unit-loss transition is missing from the engine. Any
// ->unit transition (gain / unit->unit change) already fired inline
// in the original, so `!hasUnit` prevents a double-fire there.
if (hadUnit && !hasUnit) {
auto fire = reinterpret_cast<FireEventNoArgs_t>(
static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS));
fire(Offsets::EVENT_UPDATE_MOUSEOVER_UNIT);
}
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_SET_MOUSEOVER_UNIT,
reinterpret_cast<void *>(&SetMouseover_h),
reinterpret_cast<void **>(&SetMouseover_o)};
} // namespace
} // namespace Unit::Mouseover
+43
View File
@@ -31,6 +31,22 @@ namespace {
using CancelAuraSend_t = void(__fastcall *)(int spellID);
// `FUN_UNIT_IS_OUTDOORS(unit) -> uint`, nonzero = outdoors (see Offsets.h).
// The thunk tail-jumps into the WMO query, so eax carries the result even
// though a decompiler renders the thunk itself as `void`.
using IsOutdoorsQuery_t = uint32_t(__fastcall *)(const void *unit);
// Tri-state indoor/outdoor read for the local player: 1 = outdoors,
// 0 = indoors, -1 = unknown (no resolvable player object, e.g. pre-world).
int PlayerOutdoorState() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return -1;
auto fn = reinterpret_cast<IsOutdoorsQuery_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_IS_OUTDOORS));
return fn(player) != 0 ? 1 : 0;
}
uint32_t PlayerMovementFlags() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
@@ -200,10 +216,37 @@ int __fastcall Script_IsAssistingRitual(void *L) {
return 1;
}
// `IsIndoors()` — 1 iff the local player is under a WMO roof (inside a
// building / cave / instance interior), else nil. Recomputed live from the
// engine's WMO geometry query, so it flips as you cross an interior
// threshold — not tied to the zone/area. nil before the player object
// exists (pre-world). Vanilla 1.12 ships no binding for this.
//
int __fastcall Script_IsIndoors(void *L) {
if (PlayerOutdoorState() == 0)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// `IsOutdoors()` — 1 iff the local player is outdoors (open sky, or not
// inside a WMO interior), else nil. Exact complement of `IsIndoors` for a
// resolvable player; both return nil pre-world. See `Script_IsIndoors`.
int __fastcall Script_IsOutdoors(void *L) {
if (PlayerOutdoorState() == 1)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMounted", &Script_IsMounted);
Game::Lua::RegisterGlobalFunction("IsIndoors", &Script_IsIndoors);
Game::Lua::RegisterGlobalFunction("IsOutdoors", &Script_IsOutdoors);
Game::Lua::RegisterGlobalFunction("Dismount", &Script_Dismount);
Game::Lua::RegisterGlobalFunction("IsStealthed", &Script_IsStealthed);
Game::Lua::RegisterGlobalFunction("IsFalling", &Script_IsFalling);