diff --git a/src/dpslog/RESEARCH.md b/src/dpslog/RESEARCH.md index f4c2c1f..8ee34f1 100644 --- a/src/dpslog/RESEARCH.md +++ b/src/dpslog/RESEARCH.md @@ -319,8 +319,8 @@ Covers these DPSMate events: ### Category 3: Melee Damage — DONE ✓ **Packet**: SMSG_ATTACKERSTATEUPDATE (opcode 0x14A) -**Needed event**: `COMBAT_LOG_MELEE` (allocate slot 554) -**Fields needed**: targetGUID, casterGUID, damage, school, hitInfo, blocked, absorbed, resisted, victimState +**Our event**: `COMBAT_LOG_MELEE` (slot 554) +**Fields**: targetGUID, attackerGUID, totalDamage, school, absorb, resist, blocked, hitInfo, victimState **Handler found**: Opcode 0x14A dispatches through a large switch at **0x6255B0** (registered in `UpdateBindLocation`, shared with opcodes 0x143-0x149). @@ -371,22 +371,22 @@ Research needed: verify 0x625E20 signature, what data is accessible, whether it' safe as a hook target. Covers these DPSMate events (the largest group -- 16 events): -- [ ] CHAT_MSG_COMBAT_SELF_HITS -- [ ] CHAT_MSG_COMBAT_SELF_MISSES -- [ ] CHAT_MSG_COMBAT_PARTY_HITS -- [ ] CHAT_MSG_COMBAT_PARTY_MISSES -- [ ] CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS -- [ ] CHAT_MSG_COMBAT_FRIENDLYPLAYER_MISSES -- [ ] CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS -- [ ] CHAT_MSG_COMBAT_HOSTILEPLAYER_MISSES -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS -- [ ] CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES -- [ ] CHAT_MSG_COMBAT_PET_HITS -- [ ] CHAT_MSG_COMBAT_PET_MISSES +- [x] CHAT_MSG_COMBAT_SELF_HITS +- [x] CHAT_MSG_COMBAT_SELF_MISSES +- [x] CHAT_MSG_COMBAT_PARTY_HITS +- [x] CHAT_MSG_COMBAT_PARTY_MISSES +- [x] CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS +- [x] CHAT_MSG_COMBAT_FRIENDLYPLAYER_MISSES +- [x] CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS +- [x] CHAT_MSG_COMBAT_HOSTILEPLAYER_MISSES +- [x] CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS +- [x] CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES +- [x] CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS +- [x] CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES +- [x] CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS +- [x] CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES +- [x] CHAT_MSG_COMBAT_PET_HITS +- [x] CHAT_MSG_COMBAT_PET_MISSES **SMSG_ATTACKERSTATEUPDATE packet format** (from mangos/wowdev): ``` @@ -412,51 +412,50 @@ Tracing approaches: - Trace from `ProcessUnitUpdateWithTimingValidation` (0x618c30) - Search for callers of functions that reference melee hit strings -### Category 4: Direct Heals — TODO +### Category 4: Direct Heals — DONE ✓ -**Packet**: SMSG_SPELLHEALLOG -**Handler**: Trace from `ProcessHealingSpell` (0x622420) callers -**Our event**: `COMBAT_LOG_HEAL` (slot 553 — already registered, no hook yet) -**Fields needed**: targetGUID, casterGUID, spellId, healAmount, isCrit +**Packet**: SMSG_SPELLHEALLOG (opcode 0x150, handler 0x5E89C0) +**Our event**: `COMBAT_LOG_HEAL` (slot 553) +**Fields**: targetGUID, casterGUID, spellId, healAmount, isCrit Covers these DPSMate events: -- [ ] CHAT_MSG_SPELL_SELF_BUFF (heal component: "Your X heals Y for Z") -- [ ] CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF (heal component: "X's Y heals Z for W") -- [ ] CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF (heal component) -- [ ] CHAT_MSG_SPELL_PARTY_BUFF (heal component) +- [x] CHAT_MSG_SPELL_SELF_BUFF (heal component: "Your X heals Y for Z") +- [x] CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF (heal component: "X's Y heals Z for W") +- [x] CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF (heal component) +- [x] CHAT_MSG_SPELL_PARTY_BUFF (heal component) -### Category 5: Environmental Damage — TODO (low priority) +### Category 5: Environmental Damage — DONE ✓ -**Handler**: `ProcessEnvironmentalDamage` (0x62aac0) -**Fields needed**: targetGUID, damageType (fall/fire/lava/drown/slime), amount +**Handler**: `ProcessEnvironmentalDamage` (0x62aac0) — downstream function, not a packet handler +**Our event**: `COMBAT_LOG_ENV_DMG` (slot 555) +**Fields**: victimGUID, damageType, baseDamage, absorb -DPSMate currently infers these from CHAT_MSG_COMBAT_SELF_HITS / FRIENDLYPLAYER_HITS -by matching strings like "fall and lose", "swimming in lava", "suffer X points of -fire damage", "drowning and lose". Our melee/spell hooks won't cover these since -they come from a separate opcode (SMSG_ENVIRONMENTALDAMAGELOG). +damageType values: 0=EXHAUSTED(fatigue), 1=DROWNING, 2=FALL, 3=LAVA, 4=SLIME, 5=FIRE -### Category 6: Aura Gain/Loss — TODO (low priority) +Hook approach: direct hook on ProcessEnvironmentalDamage (called from SMSG_ENVIRONMENTALDAMAGELOG +packet handler chain). Already has parsed structured data — no CDataStore parsing needed. +__fastcall(ECX=victimGuidPtr, EDX=damageType, stack: damageSource, baseDamage, absorb), returns void. -No single packet handler — these are derived from SMSG_AURA_UPDATE and various -spell effect handlers. DPSMate uses: -- [ ] CHAT_MSG_SPELL_AURA_GONE_SELF -- [ ] CHAT_MSG_SPELL_AURA_GONE_OTHER -- [ ] CHAT_MSG_SPELL_AURA_GONE_PARTY -- [ ] CHAT_MSG_SPELL_BREAK_AURA (dispel confirmation) -- [ ] PLAYER_AURAS_CHANGED +### Category 6: Aura Gain/Loss — DONE ✓ -These track: buff uptime, debuff uptime, proc tracking, shield absorb tracking, -CC tracking, HoT application/removal. Complex — may be better to let addons -continue using the existing text events for these until we have more bandwidth. +Hooked via object field update callbacks (no packet handler — vanilla uses SMSG_UPDATE_OBJECT): +- [x] SPELL_AURA_APPLIED: SetSpellTarget (0x6123F0) — aura field inactive→active transition +- [x] SPELL_AURA_REMOVED: CastSpell (0x612320) — aura field active→inactive transition +- [x] SPELL_AURA_APPLIED_DOSE: ValidateSpellSlot (0x612450) — stack count increase +- [x] SPELL_AURA_REMOVED_DOSE: ValidateSpellSlot (0x612450) — stack count decrease +- [x] SPELL_DISPEL: ProcessAuraDispelMessage (0x62D480) — from SMSG_SPELLDISPELLOG -### Category 7: Deaths — TODO (low priority) +Aura type heuristic: slot < 40 = "BUFF", slot >= 40 = "DEBUFF" (vanilla aura slot layout). +Source GUID unknown for field update hooks — passed as GUID_ZERO (no UNIT_FIELD_AURA_CREATOR in vanilla). -- [ ] CHAT_MSG_COMBAT_FRIENDLY_DEATH ("X dies.", "X is slain by Y") -- [ ] CHAT_MSG_COMBAT_HOSTILE_DEATH ("X dies.") +Still missing: +- [ ] SPELL_AURA_REFRESH: no callback for same-spell re-application +- [ ] SPELL_AURA_BROKEN / BROKEN_SPELL: CC break detection -DPSMate uses these to call `DB:UnregisterDeath(source)`. Could fire from -SMSG_DESTROYOBJECT or unit flag changes, but low impact — death events are -infrequent and the string parsing is trivial. +### Category 7: Deaths — DONE ✓ + +- [x] UNIT_DIED: HandleUnitDeath (0x605860) — called from HandleUnitHealthChange on death transition +- [x] PARTY_KILL: PartyKillLogHandler (0x628890) — from SMSG_PARTYKILLLOG packet ### Category 8: Interrupts — Embedded in spell damage events @@ -474,19 +473,153 @@ would need a separate hook or could be left to text events. --- +## FrameScript_CreateEvents Crash — Root Cause (2026-03-10) + +### The Crash +- Address 0x703E19 inside `FrameScript_CreateEvents` (0x703D90) +- ACCESS_VIOLATION reading 0x451C4000 (garbage pointer as event name string) +- Deterministic: EAX=0x451C4000, EBX=0x1A0(416), ESI=0x1A(26) in both crashes + +### Root Cause (TWO bugs) + +**Bug 1: Second call site with different event table.** +`FrameScript_CreateEvents` is `__fastcall(ECX=eventNameArray, EDX=eventCount)`. There are +TWO call sites: +- 0x48FEAB: `MOV ECX, 0xBE1198` (main event table, 549 entries) +- 0x46A88F: `MOV ECX, 0xB41E70` (secondary event table, much smaller) + +Our hook bumped maxEventId unconditionally. When the secondary call came through with +ECX=0xB41E70 and a small count, we bumped it to 557. The function iterated 557 entries +from a much smaller array, reading garbage at index 26. EBX=0x1A0 = 26*0x10 (destination +offset), ESI=0x1A = 26 (loop counter). + +**Bug 2: Stomping float globals beyond the event array.** +Slots 551-555 (addresses 0xBE1A34-0xBE1A44) are NOT free space. Ghidra xrefs show: +- 0xBE1A34: float written by 0x51C2CC (FSTP) +- 0xBE1A40: float written by 0x51BE5C (FSTP) +- 0xBE1A44: float written by 0x51C09C (FSTP) +All in the 0x51xxxx addon/UI system. Zeroing/overwriting these corrupts game state. + +### Fix +1. Guard count bump: `if (param1 == 0xBE1198 and new_max < 551)` — only expand main table +2. Single event slot 549 (verified safe by nampower) instead of 551-555 +3. Unified COMBAT_LOG_EVENT with WotLK-style subevent strings, bump to 551 + ## Implementation Status -### Done -- [x] Custom event registration (slots 551-554, FrameScript_CreateEvents hook, maxId 556) +### Done — 32 subevents across 23 hooks (22 hook addresses + wow.zig import) + +**Infrastructure:** +- [x] Unified COMBAT_LOG_EVENT at slot 549 (WotLK-style subevents) +- [x] FrameScript_CreateEvents hook — guarded for main table only, bump to 550 - [x] CDataStore read helpers (cdsGet, cdsGetPackedGuid, save/restore m_read) -- [x] GUID-to-string conversion -- [x] SignalEventParam typed wrappers -- [x] SpellNonMeleeDmgLogHandler hook (0x5E85E0) → COMBAT_LOG_SPELL_DMG -- [x] PeriodicAuraLogHandler hook (0x626DD0) → COMBAT_LOG_PERIODIC -- [x] SpellHealLogHandler hook (0x5E89C0) → COMBAT_LOG_HEAL (opcode 0x150) -- [x] MeleeDispatcher hook (0x6255B0) → COMBAT_LOG_MELEE (opcode 0x14A filter) +- [x] GUID-to-string conversion (4-buffer rotating hex formatter) +- [x] Fire functions: fireCombatLog, fireSwingMissed, fireSpellStr, fireSpell, fireBase, fireSpellStrD, fireSpellDispel - [x] Event string pointer base verified: 0xBE1198 (derived from nampower 549→0xBE1A2C) +**Phase 1-2 — Packet handler hooks:** +- [x] SpellNonMeleeDmgLogHandler (0x5E85E0) → SPELL_DAMAGE + RANGE_DAMAGE (opcode 0x250) +- [x] PeriodicAuraLogHandler (0x626DD0) → SPELL_PERIODIC_DAMAGE/HEAL/ENERGIZE/LEECH (opcode 0x24E) +- [x] MeleeDispatcher (0x6255B0) → SWING_DAMAGE / SWING_MISSED (opcode 0x14A filter) +- [x] PartyKillLogHandler (0x628890) → PARTY_KILL (opcode 0x1F5) +- [x] SpellStartHandler (0x6E7640) → SPELL_CAST_START (0x131) / SPELL_CAST_SUCCESS (0x132) +- [x] CastResultHandler (0x6E7330) → SPELL_CAST_FAILED (opcode 0x130, status != 0) + +**Phase 3 — Downstream hooks (Warden-safe):** +- [x] ProcessSpellPowerDrainMessage (0x62C770) → SPELL_HEAL (migrated from packet handler) +- [x] ProcessSpellCombatResult (0x62BAB0) → SPELL_MISSED +- [x] ProcessSpellDrainEffectMessage (0x62CA20) → DAMAGE_SHIELD +- [x] DisplaySpellInterruptMessage (0x626A10) → SPELL_INTERRUPT +- [x] ProcessInstaKillSpellMessage (0x62CBE0) → SPELL_INSTAKILL +- [x] ~~ProcessEnergize (0x62DC10)~~ → removed, was OPEN_LOCK (effectType 33), not energize +- [x] ProcessEnvironmentalDamage (0x62AAC0) → ENVIRONMENTAL_DAMAGE + +**Phase 4 — Death and aura field change hooks:** +- [x] HandleUnitDeath (0x605860) → UNIT_DIED (fastcall, ECX=unit, plain RET) +- [x] SetSpellTarget (0x6123F0) → SPELL_AURA_APPLIED (thiscall, RET 0x8) +- [x] CastSpell (0x612320) → SPELL_AURA_REMOVED (thiscall, RET 0x8) + +**Phase 5 — Dose, extra attacks, dispel, ranged detection:** +- [x] ValidateSpellSlot (0x612450) → SPELL_AURA_APPLIED_DOSE / SPELL_AURA_REMOVED_DOSE (thiscall, RET 0x8) +- [x] ProcessExtraAttacksSpellMessage (0x62D9F0) → SPELL_EXTRA_ATTACKS (fastcall, RET 0x4) +- [x] ProcessAuraDispelMessage (0x62D480) → SPELL_DISPEL (fastcall, RET 0x4) +- [x] RANGE_DAMAGE — detected via spell ID check (75=Auto Shot, 5019=Shoot) in spell damage hook +- [x] RANGE_MISSED — detected via spell ID check (75/5019) in spell missed hook +- [x] ProcessSpellEffect (0x62ACE0) → SPELL_SUMMON / SPELL_RESURRECT / SPELL_ENERGIZE (spell DB Effect[] lookup) + - ENERGIZE moved here: effectType 30 falls to default case → ProcessSpellEffect, not 0x62DC10 + +**Phase 6 — Aura refresh + bug fixes:** +- [x] SetActionCooldownTimer (0x4E4390) → SPELL_AURA_REFRESH (⚠ LOCAL PLAYER ONLY) + - Hooked from SMSG_UPDATE_AURA_DURATION (opcode 0x137) handler + - Detects refresh by checking if aura slot already has active spell in descriptors + - Vanilla only sends duration updates to the buffed player — other units' refreshes are invisible +- [x] SPELL_AURA_REFRESH heuristic for all other units via SPELL_GO hit target parsing + - Extended spellStartDetour to parse SMSG_SPELL_GO (0x132) hit target list + - For aura spells (APPLY_AURA/APPLY_AREA_AURA effects), checks if hit target already has the aura + - Skips local player (handled by SetActionCooldownTimer), fires for all other units + - Helpers: hasApplyAuraEffect(spellId), unitHasAura(guid, spellId) → ?slot +- [x] SPELL_AURA_BROKEN_SPELL heuristic via damage ring buffer (16 entries) + - Damage hooks record {target, source, spellId} into ring buffer + - auraRemovedDetour checks SpellRec.AuraInterruptFlags (+0x58) for damage-break flags + - If breakable + recent damage → fire SPELL_AURA_BROKEN_SPELL before SPELL_AURA_REMOVED +- [x] ENERGIZE false-fire bug fixed: removed 0x62DC10 hook, moved detection to ProcessSpellEffect with spell DB filter +- [x] Aura BUFF/DEBUFF detection improved: uses spell DB Attributes flag 0x4000000 (SPELL_ATTR_NEGATIVE) with slot < 40 fallback + +### Known Issues — Fixed +- ~~**SPELL_ENERGIZE false fires**~~: Fixed. Removed 0x62DC10 hook (was OPEN_LOCK/effectType 33). + ENERGIZE now detected via spell DB Effect[] check in ProcessSpellEffect (0x62ACE0). + +### Known Limitations +- **SPELL_AURA_REFRESH heuristic for other units**: For non-local units, vanilla sends NO + refresh notification at all (no packet, no descriptor change). We use a SPELL_GO heuristic: + when a buff spell hits a target that already has that aura in their descriptors, we infer + a refresh. This is best-effort and has known limitations: + - False positives: spell hits target but aura was actually removed and re-applied (rare) + - False negatives: procs, channeled refreshes, or any refresh without a SPELL_GO packet + - Only works for spells with APPLY_AURA (6) or APPLY_AREA_AURA (27) effects in spell DB + - Local player excluded (uses reliable SetActionCooldownTimer path instead) + +### SPELL_AURA_BROKEN_SPELL — Heuristic via Damage Ring Buffer +- [x] Implemented as heuristic: damage ring buffer (16 entries) + aura removal correlation +- Damage hooks (SPELL_DAMAGE, SWING_DAMAGE, SPELL_PERIODIC_DAMAGE, ENVIRONMENTAL_DAMAGE) + record {targetGUID, sourceGUID, spellId} into ring buffer via recordDamage() +- In auraRemovedDetour: if removed aura has AURA_INTERRUPT_FLAG_DAMAGE (0x02) or + AURA_INTERRUPT_FLAG_DIRECT_DAMAGE (0x01000000) in SpellRec.AuraInterruptFlags (+0x58), + check ring buffer for recent damage to same target +- If match: fire SPELL_AURA_BROKEN_SPELL with breaking source info, then SPELL_AURA_REMOVED +- Server confirms: AuraRemoveMode is tracked internally but NEVER sent to client +- No timestamp needed: damage packets arrive BEFORE descriptor updates (same server tick) +- Known limitations: false positives if damage + unrelated aura removal happen in same batch; + no coverage for SPELL_AURA_BROKEN (without spell — melee break) since we pass spellId=0 for melee + +### Completed reference +- [x] RANGE_MISSED: Detected via spell ID check (75/5019) in ProcessSpellCombatResult hook. +- [x] SPELL_SUMMON / SPELL_RESURRECT / SPELL_ENERGIZE: Hook ProcessSpellEffect (0x62ACE0), + spell DB Effect[] lookup. Summon: 28, 42, 56, 85-88, 104-107, 109. Resurrect: 18, 113. + Energize: 30. SpellRec via WowClientDB 0xC0D780: m_recordsById(+8), m_maxId(+C), + Effect[3] at +0xF4, School at +0x04, Attributes at +0x18. + +### SMSG_UPDATE_AURA_DURATION (opcode 0x137) — Aura Refresh Research +- Packet: `uint8 slot` + `uint32 duration_ms` (5 bytes) +- Handler: multi-opcode dispatcher at 0x5E38C0, case at 0x5E3BA9 +- Calls `SetActionCooldownTimer` (0x4E4390): `__fastcall(ECX=slot, EDX=duration_ms)` +- Stores `current_timestamp + duration_ms` into expiry array at 0xBC5F68 (48 entries) +- Server sends on both initial application AND refresh (Refresh() in SpellAuras.cpp:362) +- Server only sends to the player themselves (GetTarget()->GetTypeId() == TYPEID_PLAYER) +- On initial application: SMSG_UPDATE_AURA_DURATION sent BEFORE SMSG_UPDATE_OBJECT + (duration sent immediately from AddSpellAuraHolder, descriptors batched at end of tick) + → client descriptor slot is still empty when handler fires → detectable as "not refresh" +- On refresh: descriptor already has the spell from earlier application → detectable as refresh + +### CRITICAL: Wrong opcode labels from Ghidra handler dump +Many labels in `/tmp/handlers_output.txt` were user-applied and WRONG. The server source +(`reference/server/src/game/Protocol/Opcodes_1_12_1.h`) is the definitive reference: +- 0x15B = SMSG_RESURRECT_REQUEST (was labeled "handleSpellMiss" — **WRONG**) +- 0x12A = SMSG_INITIAL_SPELLS (was labeled "applySpellAura" — **WRONG**) +- 0x129 = SMSG_ACTION_BUTTONS (was labeled "removeSpellAura" — **WRONG**) +- 0x1F1 = MSG_SAVE_GUILD_EMBLEM (was labeled "applyDamageShield" — **WRONG**) +- The SPELL_MISSED hook on 0x5E7BC0 was removed — it was hooking the resurrect handler + ### Melee Hook Research Findings - 0x625E20 (originally targeted as "post-parse handler") is just a flag-setter — too simple - Dispatcher 0x6255B0 handles opcodes 0x143-0x14A via switch/jump table (byte lookup at 0x625B0C, targets at 0x625AEC) @@ -502,10 +635,284 @@ would need a separate hook or could be left to text events. - Same prologue/convention as SpellNonMeleeDmgLogHandler - Packet format verified from disassembly: targetGuid(PackedGuid), casterGuid(PackedGuid), spellId(u32), healAmount(u32), isCrit(u8) +### Verified Opcode → Handler Map (from server Opcodes_1_12_1.h) + +| Opcode | Packet Name | Client Handler | Status | +|--------|-------------|----------------|--------| +| 0x130 | SMSG_CAST_RESULT | 0x6E7330 | Hooked (SPELL_CAST_FAILED) | +| 0x131 | SMSG_SPELL_START | 0x6E7640 | Hooked (SPELL_CAST_START) | +| 0x132 | SMSG_SPELL_GO | 0x6E7640 | Hooked (SPELL_CAST_SUCCESS) | +| 0x14A | SMSG_ATTACKERSTATEUPDATE | 0x6255B0 | Hooked (SWING_DAMAGE/MISSED) | +| 0x150 | SMSG_SPELLHEALLOG | 0x5E89C0 | Hooked (SPELL_HEAL) | +| 0x1F5 | SMSG_PARTYKILLLOG | 0x628890 | Hooked (PARTY_KILL) | +| 0x24B | SMSG_SPELLLOGMISS | 0x5E7E00 | Downstream hooked (0x62BAB0 → SPELL_MISSED) | +| 0x24C | SMSG_SPELLLOGEXECUTE | 0x5E7F90 | Downstream: 0x626A10 (INTERRUPT), 0x62DC10 (ENERGIZE), 0x62D9F0 (EXTRA_ATTACKS) | +| 0x24E | SMSG_PERIODICAURALOG | 0x626DD0 | Hooked (SPELL_PERIODIC_*) | +| 0x24F | SMSG_SPELLDAMAGESHIELD | 0x5E84E0 | Downstream hooked (0x62CA20 → DAMAGE_SHIELD) | +| 0x250 | SMSG_SPELLNONMELEEDAMAGELOG | 0x5E85E0 | Hooked (SPELL_DAMAGE) | +| 0x260 | SMSG_PROCRESIST | — | Low priority | +| 0x262 | SMSG_DISPEL_FAILED | — | Low priority (failed dispels, not successful) | +| 0x27B | SMSG_SPELLDISPELLOG | 0x5E8B60 | Downstream hooked (0x62D480 → SPELL_DISPEL) | +| 0x32F | SMSG_SPELLINSTAKILLLOG | 0x5E85A0 | Downstream hooked (0x62CBE0 → SPELL_INSTAKILL) | + +### Server Packet Formats (from tortoise-wow source) + +**SMSG_SPELLLOGMISS (0x24B)**: `u32 spellId, u64 casterGuid, u8 unk8, u32 targetCount, + [targetCount]: u64 targetGuid, u8 missInfo, (if unk8==1: 2*f32)` + +**SMSG_SPELLLOGEXECUTE (0x24C)**: `PackedGuid casterGuid, u32 spellId, u32 effectCount, + [effectCount]: u32 effectType, u32 entryCount, [entryCount]: (per-effect data)` + Effect types (vanilla numbering — differs from WotLK!): INSTAKILL(1), POWER_DRAIN(8), + RESURRECT(18), EXTRA_ATTACKS(19), CREATE_ITEM(24), OPEN_LOCK(33), ENERGIZE(59?), + INTERRUPT_CAST(68), FEED_PET(101), DISMISS_PET(102), DURABILITY_DAMAGE(111). + DISPEL(38), DISPEL_MECHANIC(108) route through generic ProcessSpellEffect. + See "SPELLLOGEXECUTE Dispatch Table" section for full mapping. + +**SMSG_SPELLDAMAGESHIELD (0x24F)**: `u64 victimGuid, u64 casterGuid, u32 damage, u32 school` + +**SMSG_SPELLINSTAKILLLOG (0x32F)**: `u64 casterGuid, u32 spellId` (no victim — client-only) + +### Downstream Hook Targets (Warden-safe) + +Hooking packet handlers directly risks Warden false flags. Instead hook the downstream +functions called AFTER packet parsing with already-parsed data: + +**ProcessSpellCombatResult (0x62BAB0)** — SPELL_MISSED + `__fastcall(missType_ECX, spellId_EDX, casterGuidLo, casterGuidHi, targetGuidLo, targetGuidHi, isFromSpellLogMiss)` + Called by SPELLLOGMISS handler (0x5E7E00) per target. Dispatches by missType to + GetSpellResistString/DodgeString/etc, then DisplayColoredMessage + LogSpellCastError(0x21E). + missType enum: 2=RESIST, 3=DODGE, 4=PARRY, 5=BLOCK, 6=EVADE, 7/8=IMMUNE, 9=DEFLECT, 11=REFLECT, default=MISS + +**ProcessSpellDrainEffectMessage (0x62CA20)** — DAMAGE_SHIELD + `__fastcall(ECX=victimGuid*, EDX=casterGuid*, stack: damage, school)` + Called by SPELLDAMAGESHIELD handler (0x5E84E0). Resolves GUIDs via ValidateSpellCastAndGetObjects, + displays via DisplayColoredMessage. Also fires UNIT_COMBAT (0xB6) via InitMemoryPoolWrapper. + +**ProcessInstaKillSpellMessage (0x62CBE0)** — SPELL_INSTAKILL + `__fastcall(ECX=casterGuid*, EDX=spellId)` + Called by SPELLINSTAKILLLOG handler (0x5E85A0). Only has caster + spellId, no victim. + Displays via DisplayColoredMessage, no UNIT_COMBAT event. + +**DisplaySpellInterruptMessage (0x626A10)** — SPELL_INTERRUPT (from SPELLLOGEXECUTE) + `__fastcall(ECX=casterGuid*, EDX=targetGuid*, stack: interruptedSpellId)` RET 0x4 + Called from SPELLLOGEXECUTE handler for effectType 32 (INTERRUPT_CAST). + Missing the interrupting spell's ID (only has interrupted spell). + Ghidra name: DisplaySpellInterruptMessage. Verified: ECX→[EBP-0x10], EDX→[EBP-0xC], [EBP+0x8]=spellId. + +**ProcessEnergize (0x62DC10)** — SPELL_ENERGIZE (from SPELLLOGEXECUTE) + `__fastcall(ECX=casterGuid*, EDX=targetGuid*, stack: spellId)` RET 0x4 + Called from SPELLLOGEXECUTE for effectType 64 (TRIGGER_SPELL). + Ghidra name: ProcessLockPickingValidationMessage (mislabeled). Unusual prologue: PUSH EBX; MOV EBX,ESP; AND ESP,-8. + Stack param via [EBX+0x8]. Amount/powerType not available at this call site. + +**ProcessSpellCastEffect (0x62CD80)** — existing SPELL_DAMAGE downstream + `__fastcall(ECX=targetGuid*, EDX=casterGuid*, stack: spellId, damage, casterId, resist, absorb, blocked, flags)` RET 0x1c + Called by SPELLNONMELEEDAMAGELOG handler for direct (non-periodic) damage. + Ghidra decompile confirms: [EBP+0x8]=spellId (DB index), [EBP+0xC]=damage, [EBP+0x10]=casterId + (passed to GetSpellNameById AND compared to GetCurrentPlayerId for message formatting), + [EBP+0x14]=resist, [EBP+0x18]=absorb, [EBP+0x1C]=blocked, [EBP+0x20]=flags (&2=crit). + Internally calls ProcessSpellCombatResult(5,...) for full-block case. + NOTE: "casterId" is NOT spell school -- it's a caster entity identifier. School is looked up + from the spell DB entry, not passed as a parameter. Migration would lose school from our event. + DECISION: Do NOT migrate -- uncertain param3, lose school field, existing packet parser works. + +**ProcessSpellPowerDrainMessage (0x62C770)** — SPELL_HEAL downstream (Ghidra mislabel, actually heal display) + `__fastcall(ECX=casterGuid*, EDX=targetGuid*, stack: spellId, healAmount, isCrit)` RET 0xC + Called by SpellHealLogHandler (0x5E89C0) after packet parsing. + Verified from call site disassembly: ECX=LEA[EBP-0x18]=casterGuid, EDX=LEA[EBP-0x28]=targetGuid, + push1=[EBP-0xC]=spellId, push2=[EBP-0x8]=healAmount(EDI), push3=isCrit(ESI, normalized bool). + Calls ValidateSpellCastAndGetObjects to resolve GUIDs, then GetHealingMessageConfigString or + GetCriticalHealingMessageConfigString based on isCrit. MIGRATED: replaces packet handler hook. + +### Phase 3 Migration Assessment + +Downstream migration for Warden safety was evaluated for all 7 existing packet handler hooks: + +| Hook | Downstream | Assessment | +|------|-----------|------------| +| SpellNonMeleeDmgLogHandler | ProcessSpellCastEffect (0x62CD80) | NOT migrated -- lose school field, uncertain casterId param | +| SpellHealLogHandler | ProcessSpellPowerDrainMessage (0x62C770) | MIGRATED -- clean 5-param interface | +| PeriodicAuraLogHandler | None -- IS the handler | Cannot migrate -- no downstream exists | +| MeleeDispatcher | 0x624F30 (thiscall, unit object ptr) | NOT migrated -- takes object pointer, not GUID | +| PartyKillLogHandler | Already minimal (display only) | Cannot simplify further | +| SpellStartHandler | processSpellImpactResults (0x6E7A70) | NOT migrated -- massive function (50+ vars) | +| CastResultHandler | Spell_C_SpellFailed (0x6E1A00) | NOT migrated -- current hook simpler | + +Other downstream functions discovered during research: +- `ProcessSpellSplitDamageMessage` (0x62DE60): fastcall, 2+2 params, RET 0x8. Split damage display. +- `ValidatePeriodicSpellExecution` (0x62D9A0): fastcall, 2+5 params, RET 0x14. Periodic damage path. +- `ProcessExtraAttacksSpellMessage` (0x62D9F0): Not heal (Ghidra mislabel), actually extra attacks. +- `InitMemoryPoolWithFlag` (0x494740): UNIT_COMBAT event firing (separate from COMBAT_LOG_EVENT). + +### Aura Events: No Dedicated Packets in Vanilla +Vanilla 1.12.1 has NO SMSG_AURA_UPDATE packet. Aura state is communicated via +SMSG_UPDATE_OBJECT (0x00A9) field updates to UNIT_FIELD_AURA slots. SPELL_AURA_APPLIED/REMOVED +would require hooking the internal aura field change processing, not a packet handler. +- Only sent to the casting player — source GUID from getActivePlayerGuid (0x468550) +- We fire SPELL_CAST_FAILED only when status != 0; pass generic "FAILED" string (no localized failure reason mapping yet) + +### Calling Convention Research + +All packet handlers registered via `setClientIndexedPointers` (0x5AB650) are dispatched +with the same FastCall(unk, opCode, unk2, CDataStore*) → u32 convention. This is confirmed +by 10 working hooks across 4 different registration callers: +- 0x5E3010: SpellNonMeleeDmgLogHandler, SpellHealLogHandler +- 0x626D00: PeriodicAuraLogHandler, PartyKillLogHandler +- 0x625520: MeleeDispatcher +- 0x6E7150: SpellStartHandler, CastResultHandler + +Nampower uses two C++ types (FastCallPacketHandlerT and PacketHandlerT) but hadesmem's +detour system handles convention translation, so this doesn't reflect the actual dispatch ABI. + ### Next Steps (priority order) -1. [ ] Environmental damage hook via ProcessEnvironmentalDamage (0x62aac0) -2. [ ] Addon-side example code showing how to register for our events -3. [ ] Nampower conflict detection via GetModuleHandleA("nampower.dll") (low priority) +1. [x] Migrate existing packet handler hooks to downstream equivalents (Warden safety) + - [x] SPELL_HEAL: 0x5E89C0 → ProcessSpellPowerDrainMessage (0x62C770). MIGRATED. + - [x] SPELL_DAMAGE: Evaluated, NOT migrated (lose school field, uncertain params) + - [x] All others: Evaluated, NOT migrated (see Phase 3 Migration Assessment above) +2. [x] Implement UNIT_DIED: hook HandleUnitDeath (0x605860), fastcall(ECX=unit), plain RET +3. [x] Implement SPELL_AURA_REMOVED: hook CastSpell (0x612320), thiscall(ECX=unit, slot, spellId), RET 0x8 +4. [x] Implement SPELL_AURA_APPLIED: hook SetSpellTarget (0x6123F0), thiscall(ECX=unit, slot, spellId), RET 0x8 + - Aura type heuristic: slot < 40 = "BUFF", slot >= 40 = "DEBUFF" +5. [x] Implement SPELL_AURA_APPLIED_DOSE/REMOVED_DOSE: hook ValidateSpellSlot (0x612450) + - requestedLevel param = OLD count, live descriptor has NEW count + - Fires both directions: increase → APPLIED_DOSE, decrease → REMOVED_DOSE +6. [x] Implement SPELL_EXTRA_ATTACKS: hook ProcessExtraAttacksSpellMessage (0x62D9F0) + - effectType 19 in vanilla's SPELLLOGEXECUTE dispatch (NOT 33 as in WotLK) + - Extra attack count not available from downstream — passed as 0 +7. [x] Implement SPELL_DISPEL: hook ProcessAuraDispelMessage (0x62D480) + - Dedicated SMSG_SPELLDISPELLOG handler at 0x5E8B60 calls this per dispelled aura + - Has caster GUID, target GUID, dispelled aura spell ID + - Dispelling spell ID unknown — passed as 0 +8. [x] Implement RANGE_DAMAGE: detect Auto Shot (75) / Shoot (5019) in spell damage hook +9. [ ] Implement SPELL_AURA_REFRESH: no callback for same-spell-ID aura re-application +10. [ ] Implement SPELL_AURA_BROKEN/BROKEN_SPELL: CC break detection — deep aura system research +11. [ ] Implement SPELL_SUMMON: effectType 28+ → ProcessSpellEffect (0x62ACE0, generic — no effect type param) +12. [ ] Implement SPELL_RESURRECT: effectType 18/32/113 → ProcessSpellEffect (generic) +13. [ ] Implement RANGE_MISSED: detect Auto Shot/Shoot in SPELLLOGMISS downstream +14. [ ] Test in-game: verify all subevents fire correctly +15. [ ] Nampower conflict detection via GetModuleHandleA("nampower.dll") +16. [ ] Example addon code for COMBAT_LOG_EVENT consumption + +### Phase 4: UNIT_DIED, SPELL_AURA_APPLIED/REMOVED, RANGE_DAMAGE + +#### Object Field Update Callback System + +The client registers field change callbacks via `ObjectData_UpdateField` (0x468070): +``` +ObjectData_UpdateField(objectType, fieldOffset, dataSize, callbackFunc, context, flags) +``` + +Key registrations in `InitializeUnitEventHandlers` (0x6041F0): +- `(3, 0x40, 4, 0x6046F0, 0, 0)` -- UNIT_FIELD_HEALTH -> HandleUnitHealthChange +- `(3, 0xA4, 0xD8, 0x604D00, 0, 1)` -- UNIT_FIELD_AURA range -> compareAndUpdateObjectArrays +- `(3, eventId+0x1AC, 1, 0x604EA0, 0, 0)` -- UNIT_FIELD_AURAAPPLICATIONS -> updateObjectWithByteValue + +Callbacks are `__stdcall(guidLo, guidHi, oldDataPtr, ???)` with RET 0x10 (4 stack params). +The oldDataPtr contains the PREVIOUS field values before the update was applied. + +#### UNIT_DIED -- HandleUnitDeath (0x605860) + +- `__fastcall(ECX=unitObject)`, plain RET (0 stack params) +- Called from `HandleUnitHealthChange` (0x6046F0) when `newHealth < 1 && oldHealth > 0` +- Single xref from HandleUnitHealthChange -- only fires on actual death transition +- GUID access: `*(*(unitObject+8))` = GUID lo, `*(*(unitObject+8)+4)` = GUID hi +- Also handles: spell interruption, target clearing, action queue cleanup, event notifications + +``` +HandleUnitHealthChange (0x6046F0) __stdcall(guidLo, guidHi, oldHealthPtr, ???) + -> resolves unit object via ClntObjMgrObjectPtr + -> if newHealth < 1 && oldHealth > 0: + HandleUnitDeath(unitObject) + -> if newHealth > 0 && oldHealth < 1: + HandleUnitResurrection(unitObject) +``` + +Hook target: `HandleUnitDeath` (0x605860) -- fire `UNIT_DIED` with target GUID, no source. + +#### SPELL_AURA_APPLIED/REMOVED -- Aura Field Change Callback + +The aura callback at 0x604D00 (`compareAndUpdateObjectArrays`) is called when UNIT_FIELD_AURA +descriptor fields change (offsets 0xA4-0x17B, 48 spell ID slots + flags). It receives: +- `dataArray` = pointer to OLD aura data (before update was applied) +- Current (NEW) data is in the live descriptor at `*(unitObject+0x110)` + +The callback iterates all 48 slots in two passes: +1. **Pass 1 (removal)**: old aura active + new NOT active -> `CastSpell(unit, slot, oldSpellId)` +2. **Pass 2 (application)**: old NOT active + new IS active -> `SetSpellTarget(unit, slot, newSpellId)` + +Active check: spellId != 0 AND aura flags byte has bits set. +Aura flags at old offset 0xC0 relative to dataArray, at 0x164 relative to live descriptor. +Flags are packed: 2 aura slots per byte, 4 bits each, mask 0x0E for flag bits. + +**CastSpell (0x612320)** -- AURA REMOVED notification +- `__thiscall(ECX=unitObject, stack: slotIndex, spellId)`, RET 0x8 +- Single xref from compareAndUpdateObjectArrays (safe to hook) +- Does: IsActivePlayer check, camera FOV reset (for certain effects), UpdateActionsBySpellId + +**SetSpellTarget (0x6123F0)** -- AURA APPLIED notification +- `__thiscall(ECX=unitObject, stack: slotIndex, spellId)`, RET 0x8 +- Single xref from compareAndUpdateObjectArrays (safe to hook) +- Does: SetUnitTargetById, CGUnit_ProcessAuraSlot, HandleSpellManaCost, UpdateActionsBySpellId +- slotIndex 0x20-0x2F (32-47) treated as "valid spell range" for mana cost handling + +**ValidateSpellSlot (0x612450)** -- AURA DOSE change (from aura count callback 0x604EA0) +- `__thiscall(ECX=unitObject, stack: slotIndex, requestedLevel)`, called when stack count changes +- Could be used for SPELL_AURA_APPLIED_DOSE/REMOVED_DOSE + +**Limitation**: Source GUID (who applied the aura) is NOT available from field updates. +No UNIT_FIELD_AURA_CREATOR equivalent in vanilla 1.12.1. Fire with source=GUID_ZERO. + +#### RANGE_DAMAGE -- Ranged Auto-Attack Detection + +Vanilla ranged auto-attacks use the same SMSG_ATTACKERSTATEUPDATE packet as melee. +No distinct opcode or hitInfo flag for ranged. To distinguish: + +- Check attacker's UNIT_VIRTUAL_ITEM_SLOT_DISPLAY[2] (ranged weapon slot) + - Field index 0x14, byte offset 0x50 from descriptor start + - From stored ptr at obj+0x110: offset 0x38 + - If non-zero, the unit has a ranged weapon equipped +- Cross-reference with the attack being an auto-attack (no spellId in the packet) + +Lower priority -- most addons treat ranged auto-attacks as SWING_DAMAGE. + +### SPELLLOGEXECUTE Dispatch Table (0x5E7F90 — Phase 5 Ghidra Analysis) + +Jump table at 0x5E8430 with index byte table at 0x5E845B (126 entries, guard CMP ECX,0x7D). + +| Case | effectTypes | Downstream Function | +|------|------------|-------------------| +| 0 | 0(NONE), 1(INSTAKILL) | ProcessSpellEffect (0x62ACE0) with target | +| 1 | 8(POWER_DRAIN) | ProcessStandardPowerDrainMessage (0x62DBF0) | +| 2 | 18(RESURRECT), 38(DISPEL), 63, 69, 79, 91, 108(DISPEL_MECHANIC), 113, 114, 116, 125 | ProcessSpellEffect (0x62ACE0) with target | +| 3 | 19(EXTRA_ATTACKS in vanilla!) | ProcessExtraAttacksSpellMessage (0x62D9F0) | +| 4 | 24(CREATE_ITEM) | ProcessEnchantItemValidationMessage (0x62D8D0) | +| 5 | 33(OPEN_LOCK in vanilla), 59 | 0x62DC10 (OPEN_LOCK display — NOT energize) | +| 6 | 68(INTERRUPT_CAST) | DisplaySpellInterruptMessage (0x626A10) | +| 7 | 101(FEED_PET) | ProcessFeedPetValidationMessage (0x62D800) | +| 8 | 102(DISMISS_PET) | ProcessPetDismissMessage (0x62E360) | +| 9 | 111(DURABILITY_DAMAGE) | ProcessAllDurabilityDamageMessage (0x62E190) | +| 10 | Everything else (default) | ProcessSpellEffect (0x62ACE0) with NULL target | + +**CRITICAL**: Vanilla effectType numbering differs from WotLK! effectType 19 = EXTRA_ATTACKS (not 33). +effectType 33 = OPEN_LOCK (calls 0x62DC10, which we hook as SPELL_ENERGIZE — potential false fires). +effectType 30 (ENERGIZE) falls to default → ProcessSpellEffect, NOT 0x62DC10. + +ProcessSpellEffect (0x62ACE0) is a generic display function: checks spell DB entry flags, +filters by effect slot types, then calls ProcessHealingSpellEffect (with target) or +CalculateSpellPower (without target). No effect type parameter — cannot distinguish +DISPEL/SUMMON/RESURRECT from inside this function. + +### SMSG_SPELLDISPELLOG — Dedicated Dispel Handler + +**Handler**: 0x5E8B60 (registered in applySpellModifiers 0x5E3010) +**Convention**: __stdcall(opcode, CDataStore*), RET 0x8 +**Packet format**: PackedGuid casterGuid, PackedGuid targetGuid, u32 count, [count × u32 spellId] +**Downstream**: ProcessAuraDispelMessage (0x62D480) per dispelled aura + +**ProcessAuraDispelMessage (0x62D480)**: +`__fastcall(ECX=casterGUID_ptr, EDX=targetGUID_ptr, stack: dispelledSpellId)`, RET 0x4 +Displays AURADISPELSELF/AURADISPELOTHER. Only has the dispelled aura's spell ID, +not the dispelling spell. Hooked for SPELL_DISPEL. ### CDataStore Structure @@ -521,13 +928,11 @@ would need a separate hook or could be left to text events. ### Event Slot Layout ``` -Nampower: 549 → SPELL_DAMAGE_EVENT_SELF (0xBE1A2C) +Nampower: 549 → SPELL_DAMAGE_EVENT_SELF (0xBE1A2C) [overwritten by us] 550 → SPELL_DAMAGE_EVENT_OTHER (0xBE1A30) -Ours: 551 → COMBAT_LOG_SPELL_DMG (0xBE1A34) - 552 → COMBAT_LOG_PERIODIC (0xBE1A38) - 553 → COMBAT_LOG_HEAL (0xBE1A3C) - 554 → (reserved for COMBAT_LOG_MELEE) -FrameScript_CreateEvents maxEventId bumped to 555. +Ours: 549 → COMBAT_LOG_EVENT (0xBE1A2C) [unified, overwrites nampower 549] +FrameScript_CreateEvents maxEventId bumped to 550 (NOT 551 — slot 550 is a float global). +0xBE1A30 (slot 550) = float 0.25 (0x3E800000). Addresses 0xBE1A30+ are float globals — DO NOT USE. ``` --- diff --git a/src/dpslog/WeirdUtils_DPSLog/DPSLog.lua b/src/dpslog/WeirdUtils_DPSLog/DPSLog.lua new file mode 100644 index 0000000..8645fee --- /dev/null +++ b/src/dpslog/WeirdUtils_DPSLog/DPSLog.lua @@ -0,0 +1,524 @@ +-- DPS Log Tracker — popup checklist for verifying all 35 COMBAT_LOG_EVENT subevents +-- +-- All events share: arg1=subevent, arg2=sourceGUID, arg3=destGUID +-- Remaining args are subevent-specific (see layouts below). + +-- ============================================================================ +-- Reference tables +-- ============================================================================ + +-- Spell school bitmask (matches WotLK): +-- 0x01 = Physical, 0x02 = Holy, 0x04 = Fire, 0x08 = Nature, +-- 0x10 = Frost, 0x20 = Shadow, 0x40 = Arcane +-- Common combos: 0x03 = Holystrike, 0x05 = Flamestrike, 0x1C = Elemental, +-- 0x7E = Magic (all non-physical), 0x7F = Chaos (all 7) + +-- Power types: 0=Mana, 1=Rage, 2=Focus, 3=Energy, 4=Combo Points +local powerNames = { [0]="Mana", "Rage", "Focus", "Energy", "Combo Points" } + +-- Miss types (string): "MISS", "DODGE", "PARRY", "BLOCK", "EVADE", "IMMUNE", +-- "DEFLECT", "RESIST", "ABSORB", "REFLECT" + +-- Aura types (string): "BUFF", "DEBUFF" + +-- Environmental types (string): "EXHAUSTED", "DROWNING", "FALLING", "LAVA", +-- "SLIME", "FIRE" + +-- SWING_DAMAGE arg10 flags bitmask: +-- bit 0 (value 1) = glancing hit +-- bit 1 (value 2) = crushing blow +-- bit 2 (value 4) = offhand swing +-- Extract: glancing = (math.mod(flags, 2) == 1) +-- crushing = (math.mod(math.floor(flags / 2), 2) == 1) +-- offhand = (math.mod(math.floor(flags / 4), 2) == 1) +-- Combined: offhand glancing = 5, offhand crushing = 6, etc. + +-- ============================================================================ +-- Helpers +-- ============================================================================ + +local function schoolName(school) + if school == 1 then return "Physical" + elseif school == 2 then return "Holy" + elseif school == 4 then return "Fire" + elseif school == 8 then return "Nature" + elseif school == 16 then return "Frost" + elseif school == 32 then return "Shadow" + elseif school == 64 then return "Arcane" + else return "School:" .. (school or "?") end +end + +local function s(v) + if v == nil then return "nil" end + return tostring(v) +end + +-- ============================================================================ +-- Subevent definitions — ordered list of all 34 +-- ============================================================================ + +local SUBEVENTS = { + -- Pattern A: fireCombatLog (sub, src, dst, n1..n7) + "SPELL_DAMAGE", + "RANGE_DAMAGE", + "SPELL_PERIODIC_DAMAGE", + "DAMAGE_SHIELD", + "SWING_DAMAGE", + "SPELL_HEAL", + "SPELL_PERIODIC_HEAL", + "SPELL_ENERGIZE", + "SPELL_PERIODIC_ENERGIZE", + "SPELL_PERIODIC_LEECH", + "SPELL_PERIODIC_DRAIN", + "SPELL_EXTRA_ATTACKS", + -- Pattern B: fireSwingMissed (sub, src, dst, missType) + "SWING_MISSED", + -- Pattern C: fireSpellStr (sub, src, dst, spellId, school, strArg) + "SPELL_MISSED", + "SPELL_PERIODIC_MISSED", + "DAMAGE_SHIELD_MISSED", + "RANGE_MISSED", + "SPELL_AURA_APPLIED", + "SPELL_AURA_REMOVED", + "SPELL_AURA_REFRESH", + "SPELL_AURA_BROKEN", + "SPELL_CAST_FAILED", + -- Pattern D: fireSpell (sub, src, dst, spellId, school) + "SPELL_CAST_START", + "SPELL_CAST_SUCCESS", + "SPELL_SUMMON", + "SPELL_RESURRECT", + "SPELL_INSTAKILL", + -- Pattern E: fireBase (sub, src, dst) + "UNIT_DIED", + "PARTY_KILL", + -- Pattern F: fireSpellStrD (sub, src, dst, spellId, school, strArg, amount) + "SPELL_AURA_APPLIED_DOSE", + "SPELL_AURA_REMOVED_DOSE", + -- Pattern G: fireSpellDispel (sub, src, dst, spellId, school, extraId, extraSchool, auraType) + "SPELL_DISPEL", + "SPELL_INTERRUPT", + "SPELL_AURA_BROKEN_SPELL", + -- Pattern H: fireEnvDamage (sub, src, dst, envStr, amount, school, absorbed, 0, 0) + "ENVIRONMENTAL_DAMAGE", +} + +local NUM_SUBEVENTS = 35 + +-- Reverse lookup: subevent name -> index +local subeventIndex = {} +for i = 1, NUM_SUBEVENTS do + subeventIndex[SUBEVENTS[i]] = i +end + +-- Tracking state +local seen = {} -- [index] = true +local seenArgs = {} -- [index] = formatted arg string +local seenCount = 0 + +-- ============================================================================ +-- Arg formatters per subevent (return a short summary string) +-- ============================================================================ + +local function fmtSpellDmg(spellId, amount, school, resisted, absorbed, blocked, critical) + local c = (critical == 1) and " CRIT" or "" + return format("spell=%s amt=%s %s res=%s abs=%s blk=%s%s", + s(spellId), s(amount), schoolName(school or 0), s(resisted), s(absorbed), s(blocked), c) +end + +local function fmtSwingDmg(amount, school, resisted, absorbed, blocked, critical, flags) + local c = (critical == 1) and " CRIT" or "" + local f = "" + if flags and flags > 0 then + if math.mod(flags, 2) == 1 then f = f .. " GLANC" end + if math.mod(math.floor(flags / 2), 2) == 1 then f = f .. " CRUSH" end + if math.mod(math.floor(flags / 4), 2) == 1 then f = f .. " OH" end + end + return format("amt=%s %s res=%s abs=%s blk=%s%s%s", + s(amount), schoolName(school or 0), s(resisted), s(absorbed), s(blocked), c, f) +end + +local argFormatters = {} + +-- Pattern A — numeric args +argFormatters["SPELL_DAMAGE"] = function(a4,a5,a6,a7,a8,a9,a10) return fmtSpellDmg(a4,a5,a6,a7,a8,a9,a10) end +argFormatters["RANGE_DAMAGE"] = function(a4,a5,a6,a7,a8,a9,a10) return fmtSpellDmg(a4,a5,a6,a7,a8,a9,a10) end +argFormatters["SPELL_PERIODIC_DAMAGE"] = function(a4,a5,a6,a7,a8,a9,a10) return fmtSpellDmg(a4,a5,a6,a7,a8,a9,a10) end +argFormatters["DAMAGE_SHIELD"] = function(a4,a5,a6,a7,a8,a9,a10) return fmtSpellDmg(a4,a5,a6,a7,a8,a9,a10) end +argFormatters["SWING_DAMAGE"] = function(a4,a5,a6,a7,a8,a9,a10) return fmtSwingDmg(a4,a5,a6,a7,a8,a9,a10) end +argFormatters["SPELL_HEAL"] = function(a4,a5,a6,a7,a8) + local c = (a8 == 1) and " CRIT" or "" + return format("spell=%s amt=%s oh=%s abs=%s%s", s(a4), s(a5), s(a6), s(a7), c) +end +argFormatters["SPELL_PERIODIC_HEAL"] = function(a4,a5) return format("spell=%s amt=%s", s(a4), s(a5)) end +argFormatters["SPELL_ENERGIZE"] = function(a4,a5,a6) + return format("spell=%s amt=%s type=%s", s(a4), s(a5), powerNames[a6] or s(a6)) +end +argFormatters["SPELL_PERIODIC_ENERGIZE"] = function(a4,a5,a6,a7) + return format("spell=%s amt=%s over=%s type=%s", s(a4), s(a5), s(a6), powerNames[a7] or s(a7)) +end +argFormatters["SPELL_PERIODIC_LEECH"] = function(a4,a5,a6,a7,a8,a9) + return format("spell=%s amt=%s %s gain=%s abs=%s res=%s", s(a4), s(a5), schoolName(a6 or 0), s(a7), s(a8), s(a9)) +end +argFormatters["SPELL_PERIODIC_DRAIN"] = function(a4,a5,a6,a7) + return format("spell=%s amt=%s type=%s extra=%s", s(a4), s(a5), powerNames[a6] or s(a6), s(a7)) +end +argFormatters["SPELL_EXTRA_ATTACKS"] = function(a4,a5,a6) + return format("spell=%s school=%s amt=%s", s(a4), s(a5), s(a6)) +end + +-- Pattern B — swing missed +argFormatters["SWING_MISSED"] = function(a4) return s(a4) end + +-- Pattern C — spell + string +argFormatters["SPELL_MISSED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_PERIODIC_MISSED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["DAMAGE_SHIELD_MISSED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["RANGE_MISSED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_AURA_APPLIED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_AURA_REMOVED"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_AURA_REFRESH"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_AURA_BROKEN"] = function(a4,a5,a6) return format("spell=%s %s %s", s(a4), schoolName(a5 or 0), s(a6)) end +argFormatters["SPELL_CAST_FAILED"] = function(a4,a5,a6) return format("spell=%s %s reason=%s", s(a4), schoolName(a5 or 0), s(a6)) end + +-- Pattern D — spell only +argFormatters["SPELL_CAST_START"] = function(a4,a5) return format("spell=%s %s", s(a4), schoolName(a5 or 0)) end +argFormatters["SPELL_CAST_SUCCESS"] = function(a4,a5) return format("spell=%s %s", s(a4), schoolName(a5 or 0)) end +argFormatters["SPELL_SUMMON"] = function(a4,a5) return format("spell=%s %s", s(a4), schoolName(a5 or 0)) end +argFormatters["SPELL_RESURRECT"] = function(a4,a5) return format("spell=%s %s", s(a4), schoolName(a5 or 0)) end +argFormatters["SPELL_INSTAKILL"] = function(a4,a5) return format("spell=%s %s", s(a4), schoolName(a5 or 0)) end + +-- Pattern E — base (no extra args) +argFormatters["UNIT_DIED"] = function() return "" end +argFormatters["PARTY_KILL"] = function() return "" end + +-- Pattern F — spell + string + amount +argFormatters["SPELL_AURA_APPLIED_DOSE"] = function(a4,a5,a6,a7) return format("spell=%s %s %s x%s", s(a4), schoolName(a5 or 0), s(a6), s(a7)) end +argFormatters["SPELL_AURA_REMOVED_DOSE"] = function(a4,a5,a6,a7) return format("spell=%s %s %s x%s", s(a4), schoolName(a5 or 0), s(a6), s(a7)) end + +-- Pattern G — dispel/interrupt +argFormatters["SPELL_DISPEL"] = function(a4,a5,a6,a7,a8) + return format("spell=%s extra=%s %s %s", s(a4), s(a6), schoolName(a7 or 0), s(a8)) +end +argFormatters["SPELL_INTERRUPT"] = function(a4,a5,a6,a7) + return format("spell=%s extra=%s %s", s(a4), s(a6), schoolName(a7 or 0)) +end +argFormatters["SPELL_AURA_BROKEN_SPELL"] = function(a4,a5,a6,a7,a8) + return format("spell=%s extra=%s %s %s", s(a4), s(a6), schoolName(a7 or 0), s(a8)) +end + +-- Pattern H — environmental +argFormatters["ENVIRONMENTAL_DAMAGE"] = function(a4,a5,a6,a7) + return format("env=%s amt=%s %s abs=%s", s(a4), s(a5), schoolName(a6 or 0), s(a7)) +end + +-- ============================================================================ +-- Chat output handlers (preserved from original) +-- ============================================================================ + +local chatHandlers = {} + +chatHandlers["SPELL_DAMAGE"] = function(src, dst, spellId, amount, school, resisted, absorbed, blocked, critical) + local crit = critical == 1 and " (CRIT)" or "" + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff0000SPELL_DAMAGE|r %s -> %s: spell %d for %d %s%s", src, dst, spellId, amount, schoolName(school), crit)) +end +chatHandlers["RANGE_DAMAGE"] = function(src, dst, spellId, amount, school, resisted, absorbed, blocked, critical) + local crit = critical == 1 and " (CRIT)" or "" + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff0000RANGE_DAMAGE|r %s -> %s: spell %d for %d %s%s", src, dst, spellId, amount, schoolName(school), crit)) +end +chatHandlers["SWING_DAMAGE"] = function(src, dst, amount, school, resisted, absorbed, blocked, critical, flags) + local extra = "" + if critical == 1 then extra = extra .. " CRIT" end + if flags then + if math.mod(flags, 2) == 1 then extra = extra .. " GLANCING" end + if math.mod(math.floor(flags / 2), 2) == 1 then extra = extra .. " CRUSHING" end + if math.mod(math.floor(flags / 4), 2) == 1 then extra = extra .. " (OH)" end + end + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff0000SWING_DAMAGE|r %s -> %s: %d%s", src, dst, amount, extra)) +end +chatHandlers["SWING_MISSED"] = function(src, dst, missType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffaaaaaaSWING_MISSED|r %s -> %s: %s", src, dst, missType)) +end +chatHandlers["SPELL_MISSED"] = function(src, dst, spellId, spellSchool, missType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffaaaaaaSPELL_MISSED|r %s -> %s: spell %d %s", src, dst, spellId, missType)) +end +chatHandlers["RANGE_MISSED"] = function(src, dst, spellId, spellSchool, missType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cfffaaaaaaRANGE_MISSED|r %s -> %s: spell %d %s", src, dst, spellId, missType)) +end +chatHandlers["ENVIRONMENTAL_DAMAGE"] = function(src, dst, envType, amount, school, absorbed) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800ENVIRONMENTAL_DAMAGE|r %s: %s for %d", dst, envType, amount)) +end +chatHandlers["DAMAGE_SHIELD"] = function(src, dst, spellId, amount, school) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff4400DAMAGE_SHIELD|r %s -> %s: %d %s", src, dst, amount, schoolName(school))) +end +chatHandlers["SPELL_HEAL"] = function(src, dst, spellId, amount, overheal, absorbed, critical) + local crit = critical == 1 and " (CRIT)" or "" + DEFAULT_CHAT_FRAME:AddMessage(format("|cff00ff00SPELL_HEAL|r %s -> %s: spell %d for %d%s", src, dst, spellId, amount, crit)) +end +chatHandlers["SPELL_PERIODIC_HEAL"] = function(src, dst, spellId, amount) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff00cc00SPELL_PERIODIC_HEAL|r %s -> %s: spell %d for %d", src, dst, spellId, amount)) +end +chatHandlers["SPELL_PERIODIC_DAMAGE"] = function(src, dst, spellId, amount, school) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffcc0000SPELL_PERIODIC_DAMAGE|r %s -> %s: spell %d for %d %s", src, dst, spellId, amount, schoolName(school))) +end +chatHandlers["SPELL_ENERGIZE"] = function(src, dst, spellId, amount, powerType) + local pname = powerNames[powerType] or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(format("|cff4488ffSPELL_ENERGIZE|r %s -> %s: spell %d +%d %s", src, dst, spellId, amount, pname)) +end +chatHandlers["SPELL_PERIODIC_ENERGIZE"] = function(src, dst, spellId, amount, overEnergize, powerType) + local pname = powerNames[powerType] or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(format("|cff4488ffSPELL_PERIODIC_ENERGIZE|r %s -> %s: spell %d +%d %s", src, dst, spellId, amount, pname)) +end +chatHandlers["SPELL_PERIODIC_LEECH"] = function(src, dst, spellId, amount, school, gained) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff8844ffSPELL_PERIODIC_LEECH|r %s -> %s: spell %d drained %d, gained %d", src, dst, spellId, amount, gained or 0)) +end +chatHandlers["SPELL_PERIODIC_DRAIN"] = function(src, dst, spellId, amount, powerType, extraAmount) + local pname = powerNames[powerType] or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(format("|cff8844ffSPELL_PERIODIC_DRAIN|r %s -> %s: spell %d drained %d %s, gained %d", src, dst, spellId, amount, pname, extraAmount or 0)) +end +chatHandlers["SPELL_AURA_APPLIED"] = function(src, dst, spellId, spellSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff8888ffSPELL_AURA_APPLIED|r %s: spell %d (%s, %s)", dst, spellId, auraType, schoolName(spellSchool))) +end +chatHandlers["SPELL_AURA_REMOVED"] = function(src, dst, spellId, spellSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff6666ccSPELL_AURA_REMOVED|r %s: spell %d (%s)", dst, spellId, auraType)) +end +chatHandlers["SPELL_AURA_APPLIED_DOSE"] = function(src, dst, spellId, spellSchool, auraType, stacks) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff8888ffSPELL_AURA_APPLIED_DOSE|r %s: spell %d x%d (%s)", dst, spellId, stacks, auraType)) +end +chatHandlers["SPELL_AURA_REMOVED_DOSE"] = function(src, dst, spellId, spellSchool, auraType, stacks) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff6666ccSPELL_AURA_REMOVED_DOSE|r %s: spell %d x%d (%s)", dst, spellId, stacks, auraType)) +end +chatHandlers["SPELL_AURA_REFRESH"] = function(src, dst, spellId, spellSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff7777ddSPELL_AURA_REFRESH|r %s: spell %d (%s)", dst, spellId, auraType)) +end +chatHandlers["SPELL_AURA_BROKEN"] = function(src, dst, spellId, spellSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800SPELL_AURA_BROKEN|r %s -> %s: spell %d broken by melee (%s)", src, dst, spellId, auraType)) +end +chatHandlers["SPELL_AURA_BROKEN_SPELL"] = function(src, dst, spellId, spellSchool, extraSpellId, extraSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800SPELL_AURA_BROKEN_SPELL|r %s -> %s: spell %d broken by spell %d (%s)", src, dst, spellId, extraSpellId, auraType)) +end +chatHandlers["SPELL_CAST_START"] = function(src, dst, spellId, spellSchool) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffffff00SPELL_CAST_START|r %s: spell %d (%s)", src, spellId, schoolName(spellSchool))) +end +chatHandlers["SPELL_CAST_SUCCESS"] = function(src, dst, spellId) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffffff00SPELL_CAST_SUCCESS|r %s: spell %d", src, spellId)) +end +chatHandlers["SPELL_CAST_FAILED"] = function(src, dst, spellId, spellSchool, failedType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff4444SPELL_CAST_FAILED|r %s: spell %d (%s)", src, spellId, failedType)) +end +chatHandlers["SPELL_INTERRUPT"] = function(src, dst, spellId, spellSchool, extraSpellId, extraSchool) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800SPELL_INTERRUPT|r %s interrupted %s: spell %d (%s)", src, dst, extraSpellId, schoolName(extraSchool))) +end +chatHandlers["SPELL_DISPEL"] = function(src, dst, spellId, spellSchool, extraSpellId, extraSchool, auraType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff88ffffSPELL_DISPEL|r %s dispelled %s: spell %d (%s, %s)", src, dst, extraSpellId, auraType, schoolName(extraSchool))) +end +chatHandlers["SPELL_EXTRA_ATTACKS"] = function(src, dst, spellId) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800SPELL_EXTRA_ATTACKS|r %s: spell %d", src, spellId)) +end +chatHandlers["SPELL_SUMMON"] = function(src, dst, spellId) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff88ff88SPELL_SUMMON|r %s summoned %s: spell %d", src, dst, spellId)) +end +chatHandlers["SPELL_RESURRECT"] = function(src, dst, spellId) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff88ff88SPELL_RESURRECT|r %s resurrected %s: spell %d", src, dst, spellId)) +end +chatHandlers["SPELL_INSTAKILL"] = function(src, dst, spellId) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff0000SPELL_INSTAKILL|r %s killed %s: spell %d", src, dst, spellId)) +end +chatHandlers["UNIT_DIED"] = function(src, dst) + DEFAULT_CHAT_FRAME:AddMessage(format("|cff888888UNIT_DIED|r %s", dst)) +end +chatHandlers["PARTY_KILL"] = function(src, dst) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffff8800PARTY_KILL|r %s killed %s", src, dst)) +end +chatHandlers["SPELL_PERIODIC_MISSED"] = function(src, dst, spellId, spellSchool, missType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffaaaaaaSPELL_PERIODIC_MISSED|r %s -> %s: spell %d %s", src, dst, spellId, missType)) +end +chatHandlers["DAMAGE_SHIELD_MISSED"] = function(src, dst, spellId, spellSchool, missType) + DEFAULT_CHAT_FRAME:AddMessage(format("|cffaaaaaaDAMAGE_SHIELD_MISSED|r %s -> %s: spell %d %s", src, dst, spellId, missType)) +end + +-- ============================================================================ +-- UI — Draggable popup tracker +-- ============================================================================ + +local ROW_HEIGHT = 14 +local FRAME_WIDTH = 420 +local VISIBLE_ROWS = 20 +local FRAME_HEIGHT = ROW_HEIGHT * VISIBLE_ROWS + 52 -- title + counter + padding + +local main = CreateFrame("Frame", "DPSLogTracker", UIParent) +main:SetWidth(FRAME_WIDTH) +main:SetHeight(FRAME_HEIGHT) +main:SetPoint("CENTER", UIParent, "CENTER", 300, 0) +main:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, +}) +main:SetBackdropColor(0, 0, 0, 0.85) +main:EnableMouse(true) +main:SetMovable(true) +main:RegisterForDrag("LeftButton") +main:SetScript("OnDragStart", function() main:StartMoving() end) +main:SetScript("OnDragStop", function() main:StopMovingOrSizing() end) +main:SetFrameStrata("DIALOG") +main:SetClampedToScreen(true) + +-- Title +local title = main:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") +title:SetPoint("TOPLEFT", main, "TOPLEFT", 12, -10) +title:SetText("DPS Log Tracker") +title:SetTextColor(1, 0.82, 0) + +-- Close button +local closeBtn = CreateFrame("Button", nil, main, "UIPanelCloseButton") +closeBtn:SetPoint("TOPRIGHT", main, "TOPRIGHT", -2, -2) +closeBtn:SetScript("OnClick", function() main:Hide() end) + +-- Counter +local counter = main:CreateFontString(nil, "OVERLAY", "GameFontNormal") +counter:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -2) +counter:SetTextColor(0.7, 0.7, 0.7) + +local function updateCounter() + counter:SetText(format("Seen: %d/%d", seenCount, NUM_SUBEVENTS)) +end +updateCounter() + +-- Scroll frame +local scrollFrame = CreateFrame("ScrollFrame", "DPSLogTrackerScroll", main, "FauxScrollFrameTemplate") +scrollFrame:SetPoint("TOPLEFT", main, "TOPLEFT", 8, -48) +scrollFrame:SetPoint("BOTTOMRIGHT", main, "BOTTOMRIGHT", -28, 8) + +-- Row frames +local rows = {} +for i = 1, VISIBLE_ROWS do + local row = CreateFrame("Frame", nil, main) + row:SetWidth(FRAME_WIDTH - 40) + row:SetHeight(ROW_HEIGHT) + row:SetPoint("TOPLEFT", scrollFrame, "TOPLEFT", 2, -((i - 1) * ROW_HEIGHT)) + + local check = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + check:SetPoint("LEFT", row, "LEFT", 0, 0) + check:SetWidth(20) + check:SetJustifyH("LEFT") + + local nameStr = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + nameStr:SetPoint("LEFT", check, "RIGHT", 0, 0) + nameStr:SetWidth(FRAME_WIDTH - 60) + nameStr:SetJustifyH("LEFT") + + row.check = check + row.nameStr = nameStr + rows[i] = row +end + +local function updateRows() + local offset = FauxScrollFrame_GetOffset(scrollFrame) + for i = 1, VISIBLE_ROWS do + local idx = offset + i + local row = rows[i] + if idx <= NUM_SUBEVENTS then + row:Show() + if seen[idx] then + row.check:SetText("|cff00ff00x|r") + local argStr = seenArgs[idx] + if argStr and argStr ~= "" then + row.nameStr:SetText("|cffffffff" .. SUBEVENTS[idx] .. "|r |cffaaaaaa" .. argStr .. "|r") + else + row.nameStr:SetText("|cffffffff" .. SUBEVENTS[idx] .. "|r") + end + else + row.check:SetText("|cff666666-|r") + row.nameStr:SetText("|cff666666" .. SUBEVENTS[idx] .. "|r") + end + else + row:Hide() + end + end + FauxScrollFrame_Update(scrollFrame, NUM_SUBEVENTS, VISIBLE_ROWS, ROW_HEIGHT) +end + +scrollFrame:SetScript("OnVerticalScroll", function() + FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, updateRows) +end) + +updateRows() + +-- Reset button +local resetBtn = CreateFrame("Button", nil, main, "UIPanelButtonTemplate") +resetBtn:SetWidth(60) +resetBtn:SetHeight(18) +resetBtn:SetPoint("TOPRIGHT", closeBtn, "TOPLEFT", -4, -6) +resetBtn:SetText("Reset") +resetBtn:SetScript("OnClick", function() + for i = 1, NUM_SUBEVENTS do + seen[i] = nil + seenArgs[i] = nil + end + seenCount = 0 + updateCounter() + updateRows() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00DPSLog Tracker|r reset.") +end) + +-- ============================================================================ +-- Event dispatcher +-- ============================================================================ + +local function print(msg) + DEFAULT_CHAT_FRAME:AddMessage(msg) +end + +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("COMBAT_LOG_EVENT") + +eventFrame:SetScript("OnEvent", function() + -- if event == "PLAYER_LOGIN" then + -- eventFrame:RegisterEvent("COMBAT_LOG_EVENT") + -- DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00DPSLog|r registered COMBAT_LOG_EVENT") + -- return + -- end + + local subevent = arg1 + if not subevent then return end + + -- Chat output + local chatHandler = chatHandlers[subevent] + if chatHandler then + chatHandler(arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) + else + DEFAULT_CHAT_FRAME:AddMessage(format("|cffccccccUNHANDLED|r %s", subevent)) + end + + -- Tracker update (first encounter only) + local idx = subeventIndex[subevent] + if idx and not seen[idx] then + seen[idx] = true + seenCount = seenCount + 1 + local formatter = argFormatters[subevent] + if formatter then + seenArgs[idx] = formatter(arg4, arg5, arg6, arg7, arg8, arg9, arg10) + else + seenArgs[idx] = "" + end + updateCounter() + updateRows() + end +end) + +-- ============================================================================ +-- Slash command +-- ============================================================================ + +SLASH_DPSLOG1 = "/dpslog" +SlashCmdList["DPSLOG"] = function(msg) + if main:IsVisible() then + main:Hide() + else + main:Show() + end +end + +DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00DPSLog Tracker|r loaded — /dpslog to toggle window") diff --git a/src/dpslog/WeirdUtils_DPSLog/WeirdUtils_DPSLog.toc b/src/dpslog/WeirdUtils_DPSLog/WeirdUtils_DPSLog.toc new file mode 100644 index 0000000..53a1a8a --- /dev/null +++ b/src/dpslog/WeirdUtils_DPSLog/WeirdUtils_DPSLog.toc @@ -0,0 +1,6 @@ +## Interface: 11200 +## Title: [WeirdUtils] DPS Log +## Notes: WotLK-style COMBAT_LOG_EVENT for vanilla 1.12.1 +## Version: 1.0 + +DPSLog.lua diff --git a/src/dpslog/dpslog.zig b/src/dpslog/dpslog.zig index 07bb8d0..84a793d 100644 --- a/src/dpslog/dpslog.zig +++ b/src/dpslog/dpslog.zig @@ -1,36 +1,49 @@ -//! DPS log module — structured combat log events. +//! DPS log module — TBC/WotLK-style COMBAT_LOG_EVENT for vanilla 1.12.1. //! -//! Hooks packet handlers for SMSG_SPELLNONMELEEDAMAGELOG, -//! SMSG_PERIODICAURALOG, SMSG_SPELLHEALLOG, and SMSG_ATTACKERSTATEUPDATE -//! to fire custom Lua events with structured numeric/string args, so addons -//! can skip string parsing. +//! Fires a unified COMBAT_LOG_EVENT with WotLK-style subevent names and +//! structured args. All subevents share: arg1=subevent, arg2=sourceGUID, +//! arg3=destGUID. Remaining args are subevent-specific. //! -//! Custom events (registered in unused event table slots): -//! COMBAT_LOG_SPELL_DMG (551) — spell damage (direct) -//! COMBAT_LOG_PERIODIC (552) — periodic damage/heal ticks -//! COMBAT_LOG_HEAL (553) — direct heals -//! COMBAT_LOG_MELEE (554) — melee damage (auto-attack + special) +//! See reference/COMBAT_LOG_EVENT_WOTLK.md for full WotLK spec. +//! See RESEARCH.md "Implementation Status" for what's implemented vs TODO. //! -//! Lua args for COMBAT_LOG_SPELL_DMG: -//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=damage, -//! arg5=school, arg6=absorb, arg7=resist, arg8=blocked, arg9=hitInfo +//! Arg layouts per suffix type: //! -//! Lua args for COMBAT_LOG_PERIODIC: -//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=amount, -//! arg5=school, arg6=absorb, arg7=resist, arg8=auraType, arg9=powerType +//! _DAMAGE (spell prefix): arg4=spellId, arg5=amount, arg6=school, +//! arg7=resisted, arg8=absorbed, arg9=blocked, arg10=critical +//! _DAMAGE (swing): arg4=amount, arg5=school, arg6=resisted, +//! arg7=absorbed, arg8=blocked, arg9=critical, arg10=0 +//! _DAMAGE (environmental): arg4=envType, arg5=amount, arg6=absorbed, +//! arg7-10=0 +//! _MISSED (spell prefix): arg4=spellId, arg5=spellSchool, arg6=missType(string) +//! _MISSED (swing): arg4=missType(string) +//! _HEAL (spell prefix): arg4=spellId, arg5=amount, arg6=0(overheal), +//! arg7=0(absorbed), arg8=critical +//! _ENERGIZE: arg4=spellId, arg5=amount, arg6=powerType +//! _DRAIN/_LEECH: arg4=spellId, arg5=amount, arg6=powerType, +//! arg7=extraAmount +//! _AURA_APPLIED/REMOVED: arg4=spellId, arg5=spellSchool, arg6=auraType(string) +//! _AURA_APPLIED_DOSE/REMOVED_DOSE: same + arg7=amount(stacks) +//! _CAST_START/SUCCESS: arg4=spellId, arg5=spellSchool +//! _CAST_FAILED: arg4=spellId, arg5=spellSchool, arg6=failedType(string) +//! _INTERRUPT: arg4=spellId, arg5=spellSchool, arg6=extraSpellId, +//! arg7=extraSpellSchool +//! _DISPEL/_STOLEN: arg4=spellId, arg5=spellSchool, arg6=extraSpellId, +//! arg7=extraSpellSchool, arg8=auraType(string) +//! _EXTRA_ATTACKS: arg4=spellId, arg5=spellSchool, arg6=amount +//! UNIT_DIED/PARTY_KILL: (base params only) //! -//! Lua args for COMBAT_LOG_HEAL: -//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=healAmount, -//! arg5=isCrit -//! -//! Lua args for COMBAT_LOG_MELEE: -//! arg1=targetGUID, arg2=attackerGUID, arg3=totalDamage, arg4=school, -//! arg5=absorb, arg6=resist, arg7=blocked, arg8=hitInfo, arg9=victimState +//! Power types: 0=mana, 1=rage, 2=focus, 3=energy, 4=combo points +//! Env types: 0=EXHAUSTED, 1=DROWNING, 2=FALLING, 3=LAVA, 4=SLIME, 5=FIRE +//! Miss types: "MISS", "DODGE", "PARRY", "BLOCK", "EVADE", "IMMUNE", +//! "DEFLECT", "RESIST", "ABSORB", "REFLECT" +//! Aura types: "BUFF", "DEBUFF" const std = @import("std"); const hook = @import("zhook"); const logging = @import("../logging.zig"); const mod_mutex = @import("../mutex.zig"); +const wow = @import("../wow.zig"); pub const module_name: [*:0]const u8 = "dpslog"; @@ -38,60 +51,174 @@ var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; var log: logging.Logger = .{}; +// Ring buffer for recent damage events — used by SPELL_AURA_BROKEN heuristic. +// Damage packets arrive BEFORE descriptor updates (which trigger aura removal), +// so the buffer is populated by the time auraRemovedDetour checks it. +const DAMAGE_RING_SIZE = 16; +const DamageEntry = struct { + target_guid: u64 = 0, + source_guid: u64 = 0, + spell_id: u32 = 0, +}; +var damage_ring: [DAMAGE_RING_SIZE]DamageEntry = [_]DamageEntry{.{}} ** DAMAGE_RING_SIZE; +var damage_ring_idx: u8 = 0; + +fn recordDamage(target: u64, source: u64, spell_id: u32) void { + damage_ring[damage_ring_idx] = .{ .target_guid = target, .source_guid = source, .spell_id = spell_id }; + damage_ring_idx = (damage_ring_idx + 1) % DAMAGE_RING_SIZE; +} + +/// Find most recent damage to a target. Returns source GUID and spell ID. +fn findRecentDamage(target: u64) ?DamageEntry { + // Search backwards from most recent entry + var i: u8 = 0; + while (i < DAMAGE_RING_SIZE) : (i += 1) { + const idx = (damage_ring_idx -% 1 -% i) % DAMAGE_RING_SIZE; + if (damage_ring[idx].target_guid == target) return damage_ring[idx]; + } + return null; +} + +/// SpellRec AuraInterruptFlags offset and damage-break bits. +const SPELL_AURA_INTERRUPT_FLAGS_OFFSET: u32 = 0x58; +const AURA_INTERRUPT_FLAG_DAMAGE: u32 = 0x02; +const AURA_INTERRUPT_FLAG_DIRECT_DAMAGE: u32 = 0x01000000; + pub fn isActive() bool { return g_is_hook_owner; } // ============================================================================= -// Custom event IDs & names +// COMBAT_LOG_EVENT — slot assigned dynamically in createEventsDetour // ============================================================================= -/// We register events at slots 551..554 (beyond nampower's 549/550). -/// The event name string pointer array has 4-byte stride per event ID. -/// Nampower: 549→0xBE1A2C, 550→0xBE1A30 → base = 0xBE1198. -/// 551: 0xBE1A34 552: 0xBE1A38 553: 0xBE1A3C 554: 0xBE1A40 -const EVENT_SPELL_DMG: u32 = 551; -const EVENT_PERIODIC: u32 = 552; -const EVENT_HEAL: u32 = 553; -const EVENT_MELEE: u32 = 554; - -/// Derived from nampower: event 549 string ptr is at 0xBE1A2C → base = 0xBE1198. -const EVENT_STR_PTR_BASE: u32 = 0xBE1198; - -/// Static event name strings — must outlive the process. -const event_name_spell_dmg: [*:0]const u8 = "COMBAT_LOG_SPELL_DMG"; -const event_name_periodic: [*:0]const u8 = "COMBAT_LOG_PERIODIC"; -const event_name_heal: [*:0]const u8 = "COMBAT_LOG_HEAL"; -const event_name_melee: [*:0]const u8 = "COMBAT_LOG_MELEE"; - -// TODO: Detect nampower via GetModuleHandleA("nampower.dll") and skip -// overlapping SpellNonMeleeDmgLog/PeriodicAuraLog hooks if loaded (nampower -// fires SPELL_DAMAGE_EVENT_SELF/OTHER for those). Low priority — most users -// won't run both DLLs simultaneously. +var g_event_combat_log: u32 = 0; +const EVENT_TABLE_MAIN: u32 = 0xBE1198; +const event_name: [*:0]const u8 = "COMBAT_LOG_EVENT"; // ============================================================================= -// CDataStore helpers — direct memory reads from packet buffer +// Subevent name strings (WotLK naming) +// ============================================================================= + +// Damage +const SUB_SWING_DAMAGE: [*:0]const u8 = "SWING_DAMAGE"; +const SUB_RANGE_DAMAGE: [*:0]const u8 = "RANGE_DAMAGE"; +const SUB_SPELL_DAMAGE: [*:0]const u8 = "SPELL_DAMAGE"; +const SUB_SPELL_PERIODIC_DAMAGE: [*:0]const u8 = "SPELL_PERIODIC_DAMAGE"; +const SUB_DAMAGE_SHIELD: [*:0]const u8 = "DAMAGE_SHIELD"; +const SUB_ENV_DAMAGE: [*:0]const u8 = "ENVIRONMENTAL_DAMAGE"; + +// Missed +const SUB_SWING_MISSED: [*:0]const u8 = "SWING_MISSED"; +const SUB_RANGE_MISSED: [*:0]const u8 = "RANGE_MISSED"; +const SUB_SPELL_MISSED: [*:0]const u8 = "SPELL_MISSED"; +const SUB_SPELL_PERIODIC_MISSED: [*:0]const u8 = "SPELL_PERIODIC_MISSED"; +const SUB_DAMAGE_SHIELD_MISSED: [*:0]const u8 = "DAMAGE_SHIELD_MISSED"; + +// Heal +const SUB_SPELL_HEAL: [*:0]const u8 = "SPELL_HEAL"; +const SUB_SPELL_PERIODIC_HEAL: [*:0]const u8 = "SPELL_PERIODIC_HEAL"; + +// Energize / Drain +const SUB_SPELL_ENERGIZE: [*:0]const u8 = "SPELL_ENERGIZE"; +const SUB_SPELL_PERIODIC_ENERGIZE: [*:0]const u8 = "SPELL_PERIODIC_ENERGIZE"; +const SUB_SPELL_PERIODIC_DRAIN: [*:0]const u8 = "SPELL_PERIODIC_DRAIN"; +const SUB_SPELL_PERIODIC_LEECH: [*:0]const u8 = "SPELL_PERIODIC_LEECH"; + +// Aura +const SUB_SPELL_AURA_APPLIED: [*:0]const u8 = "SPELL_AURA_APPLIED"; +const SUB_SPELL_AURA_REMOVED: [*:0]const u8 = "SPELL_AURA_REMOVED"; +const SUB_SPELL_AURA_APPLIED_DOSE: [*:0]const u8 = "SPELL_AURA_APPLIED_DOSE"; +const SUB_SPELL_AURA_REMOVED_DOSE: [*:0]const u8 = "SPELL_AURA_REMOVED_DOSE"; +const SUB_SPELL_AURA_REFRESH: [*:0]const u8 = "SPELL_AURA_REFRESH"; +const SUB_SPELL_AURA_BROKEN: [*:0]const u8 = "SPELL_AURA_BROKEN"; +const SUB_SPELL_AURA_BROKEN_SPELL: [*:0]const u8 = "SPELL_AURA_BROKEN_SPELL"; + +// Cast +const SUB_SPELL_CAST_START: [*:0]const u8 = "SPELL_CAST_START"; +const SUB_SPELL_CAST_SUCCESS: [*:0]const u8 = "SPELL_CAST_SUCCESS"; +const SUB_SPELL_CAST_FAILED: [*:0]const u8 = "SPELL_CAST_FAILED"; + +// Misc +const SUB_SPELL_INTERRUPT: [*:0]const u8 = "SPELL_INTERRUPT"; +const SUB_SPELL_DISPEL: [*:0]const u8 = "SPELL_DISPEL"; +const SUB_SPELL_STOLEN: [*:0]const u8 = "SPELL_STOLEN"; +const SUB_SPELL_EXTRA_ATTACKS: [*:0]const u8 = "SPELL_EXTRA_ATTACKS"; +const SUB_SPELL_SUMMON: [*:0]const u8 = "SPELL_SUMMON"; +const SUB_SPELL_RESURRECT: [*:0]const u8 = "SPELL_RESURRECT"; +const SUB_SPELL_INSTAKILL: [*:0]const u8 = "SPELL_INSTAKILL"; +const SUB_PARTY_KILL: [*:0]const u8 = "PARTY_KILL"; +const SUB_UNIT_DIED: [*:0]const u8 = "UNIT_DIED"; + +// Miss type strings (for _MISSED suffix arg) +const MISS_MISS: [*:0]const u8 = "MISS"; +const MISS_DODGE: [*:0]const u8 = "DODGE"; +const MISS_PARRY: [*:0]const u8 = "PARRY"; +const MISS_BLOCK: [*:0]const u8 = "BLOCK"; +const MISS_EVADE: [*:0]const u8 = "EVADE"; +const MISS_IMMUNE: [*:0]const u8 = "IMMUNE"; +const MISS_DEFLECT: [*:0]const u8 = "DEFLECT"; +const MISS_RESIST: [*:0]const u8 = "RESIST"; +const MISS_ABSORB: [*:0]const u8 = "ABSORB"; +const MISS_REFLECT: [*:0]const u8 = "REFLECT"; + +/// Zero GUID for events without a source. +const GUID_ZERO: [*:0]const u8 = "0x0000000000000000"; + +// Environmental damage type strings (WotLK naming) +const ENV_EXHAUSTED: [*:0]const u8 = "EXHAUSTED"; +const ENV_DROWNING: [*:0]const u8 = "DROWNING"; +const ENV_FALLING: [*:0]const u8 = "FALLING"; +const ENV_LAVA: [*:0]const u8 = "LAVA"; +const ENV_SLIME: [*:0]const u8 = "SLIME"; +const ENV_FIRE: [*:0]const u8 = "FIRE"; + +fn envTypeToString(env_type: u32) [*:0]const u8 { + return switch (env_type) { + 0 => ENV_EXHAUSTED, + 1 => ENV_DROWNING, + 2 => ENV_FALLING, + 3 => ENV_LAVA, + 4 => ENV_SLIME, + 5 => ENV_FIRE, + else => ENV_FALLING, + }; +} + +// ============================================================================= +// Melee hitInfo flags (from cmangos) +// ============================================================================= + +const HITINFO_LEFTSWING: u32 = 0x04; +const HITINFO_MISS: u32 = 0x10; +const HITINFO_CRITICALHIT: u32 = 0x80; +const HITINFO_BLOCK: u32 = 0x800; +const HITINFO_CRUSHING: u32 = 0x2000; +const HITINFO_GLANCING: u32 = 0x4000; + +// ============================================================================= +// Unit power descriptor offsets (from m_data at obj+0x08) +// ============================================================================= + +/// UNIT_FIELD_POWER1 at absolute index 0x17. Power(N) = 0x5C + N*4. +const DESC_POWER_BASE: u32 = 0x17 * 4; // 0x5C +/// UNIT_FIELD_MAXPOWER1 at absolute index 0x1D. MaxPower(N) = 0x74 + N*4. +const DESC_MAXPOWER_BASE: u32 = 0x1D * 4; // 0x74 + +// Victim states +const VS_UNAFFECTED: u32 = 0; +const VS_NORMAL: u32 = 1; +const VS_DODGE: u32 = 2; +const VS_PARRY: u32 = 3; +const VS_INTERRUPT: u32 = 4; +const VS_BLOCK: u32 = 5; +const VS_EVADE: u32 = 6; +const VS_IMMUNE: u32 = 7; +const VS_DEFLECT: u32 = 8; + +// ============================================================================= +// CDataStore helpers // ============================================================================= -// -// CDataStore layout (from nampower cdatastore.hpp): -// +0x00: vtable -// +0x04: m_buffer (u8 pointer) -// +0x08: m_base -// +0x0C: m_alloc -// +0x10: m_size -// +0x14: m_read (current read position) -// -// NOTE: the nampower header shows m_buffer at +0x00 after vtable, but -// CDataStore is a virtual class — vtable is +0x00, members follow. -// Let's match nampower's actual memory layout which treats it as: -// m_buffer at offset after vtable. In the C++ class, the first member -// after the vtable IS m_buffer. So: -// +0x00: vtable ptr -// +0x04: m_buffer -// +0x08: m_base -// +0x0C: m_alloc -// +0x10: m_size -// +0x14: m_read const CDS_BUFFER = 0x04; const CDS_BASE = 0x08; @@ -118,7 +245,6 @@ fn cdsSize(cds: u32) u32 { return hook.readMem(u32, cds + CDS_SIZE); } -/// Read a T from the CDataStore at the current read position, advancing m_read. fn cdsGet(comptime T: type, cds: u32) ?T { const rpos = cdsGetRead(cds); const end = rpos + @sizeOf(T); @@ -130,8 +256,6 @@ fn cdsGet(comptime T: type, cds: u32) ?T { return val; } -/// Read a packed GUID from the CDataStore. Returns null on read error. -/// Packed GUID format: 1-byte mask, then one byte per set bit in the mask. fn cdsGetPackedGuid(cds: u32) ?u64 { const mask = cdsGet(u8, cds) orelse return null; var guid: u64 = 0; @@ -148,8 +272,6 @@ fn cdsGetPackedGuid(cds: u32) ?u64 { // GUID → hex string conversion // ============================================================================= -/// Convert a u64 GUID to a 0x-prefixed hex string. Returns pointer to static buffer. -/// Uses two alternating buffers so we can format target + caster in one call. var guid_bufs: [4][20]u8 = undefined; var guid_buf_idx: u2 = 0; @@ -170,62 +292,130 @@ fn guidToString(guid: u64) [*:0]const u8 { } // ============================================================================= -// SignalEventParam — fire a Lua event with structured args +// SignalEventParam fire functions — one per format pattern // ============================================================================= // // SignalEventParam (0x703F50): __cdecl(eventId, fmtStr, ...) -// The format string uses %s/%d to push args as Lua event args (arg1, arg2, ...). -// We use typed wrapper functions since Zig can't express variadic cdecl generically. +// Each fire function uses a specific format string for its arg pattern. +// On x86 cdecl, all args are 4 bytes on stack (pointers and ints alike). -/// Fire spell damage event: "%s%s%d%d%d%d%d%d%d" -/// args: targetGuid, casterGuid, spellId, damage, school, absorb, resist, blocked, hitInfo -fn fireSpellDamageEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, damage: u32, school: u32, absorb: u32, resist: i32, blocked: u32, hit_info: u32) void { - const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, i32, u32, u32) callconv(hook.cc.cdecl) void; - const f: *const SignalFn = @ptrFromInt(0x703F50); - // Format: targetGuid(s), casterGuid(s), spellId(d), damage(d), school(d), absorb(d), resist(d), blocked(d), hitInfo(d) - @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, caster, spell_id, damage, school, absorb, resist, blocked, hit_info }); +const SIGNAL: u32 = 0x703F50; + +/// Pattern A: 3 strings + 7 numbers — _DAMAGE, _HEAL, _ENERGIZE, etc. +/// subevent, srcGUID, dstGUID, n1..n7 +fn fireCombatLog(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8, n1: u32, n2: u32, n3: u32, n4: u32, n5: u32, n6: u32, n7: u32) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%d%d%d%d%d%d%d", sub, src, dst, n1, n2, n3, n4, n5, n6, n7 }); } -/// Fire periodic event: "%s%s%d%d%d%d%d%d%d" -/// Same format as spell damage but with auraType instead of hitInfo. -fn firePeriodicEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, amount: u32, school: u32, absorb: u32, resist: i32, aura_type: u32, power_type: u32) void { - const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, i32, u32, u32) callconv(hook.cc.cdecl) void; - const f: *const SignalFn = @ptrFromInt(0x703F50); - @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, caster, spell_id, amount, school, absorb, resist, aura_type, power_type }); +/// Pattern B: 4 strings — SWING_MISSED (subevent, src, dst, missType) +fn fireSwingMissed(src: [*:0]const u8, dst: [*:0]const u8, miss_type: [*:0]const u8) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%s", SUB_SWING_MISSED, src, dst, miss_type }); } -/// Fire heal event: "%s%s%d%d%d" -/// args: targetGuid, casterGuid, spellId, healAmount, isCrit -fn fireHealEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, amount: u32, is_crit: u32) void { - const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32) callconv(hook.cc.cdecl) void; - const f: *const SignalFn = @ptrFromInt(0x703F50); - @call(.auto, f, .{ event_id, "%s%s%d%d%d", target, caster, spell_id, amount, is_crit }); +/// Pattern C: 3 strings + 2 numbers + 1 string — SPELL_MISSED, SPELL_AURA_*, SPELL_CAST_FAILED +/// subevent, srcGUID, dstGUID, spellId, spellSchool, stringArg +fn fireSpellStr(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8, spell_id: u32, spell_school: u32, str_arg: [*:0]const u8) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, [*:0]const u8) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%d%d%s", sub, src, dst, spell_id, spell_school, str_arg }); +} + +/// Pattern D: 3 strings + 2 numbers — SPELL_CAST_START/SUCCESS, SPELL_SUMMON, etc. +/// subevent, srcGUID, dstGUID, spellId, spellSchool +fn fireSpell(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8, spell_id: u32, spell_school: u32) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%d%d", sub, src, dst, spell_id, spell_school }); +} + +/// Pattern E: 3 strings only — UNIT_DIED, PARTY_KILL +fn fireBase(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s", sub, src, dst }); +} + +/// Pattern F: 3s + 2d + 1s + 1d — SPELL_AURA_APPLIED_DOSE/REMOVED_DOSE +fn fireSpellStrD(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8, spell_id: u32, spell_school: u32, str_arg: [*:0]const u8, amount: u32) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, [*:0]const u8, u32) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%d%d%s%d", sub, src, dst, spell_id, spell_school, str_arg, amount }); +} + +/// Pattern G: 3s + 4d + 1s — SPELL_DISPEL/STOLEN (spellId, spellSchool, extraSpellId, extraSchool, auraType) +fn fireSpellDispel(sub: [*:0]const u8, src: [*:0]const u8, dst: [*:0]const u8, spell_id: u32, spell_school: u32, extra_id: u32, extra_school: u32, aura_type: [*:0]const u8) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, [*:0]const u8) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%d%d%d%d%s", sub, src, dst, spell_id, spell_school, extra_id, extra_school, aura_type }); +} + +/// Pattern H: 4 strings + 6 numbers — ENVIRONMENTAL_DAMAGE (envType as string) +fn fireEnvDamage(dst: [*:0]const u8, env_str: [*:0]const u8, amount: u32, school: u32, absorb: u32) void { + const F = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, u32) callconv(hook.cc.cdecl) void; + @call(.auto, @as(*const F, @ptrFromInt(SIGNAL)), .{ g_event_combat_log, "%s%s%s%s%d%d%d%d%d", SUB_ENV_DAMAGE, GUID_ZERO, dst, env_str, amount, school, absorb, 0, 0 }); } // ============================================================================= // Hook: FrameScript_CreateEvents (0x703D90) -// Expands max event count to accommodate our custom event slots. +// After the chain runs, write our event into the internal table. // ============================================================================= -/// FrameScript_CreateEvents signature: __fastcall(ECX=param1, EDX=maxEventId) -/// Nampower hooks this as (int param_1, uint32_t maxEventId) — the first two -/// args in fastcall go to ECX and EDX. var create_events_hook: hook.Detour(fn (u32, u32) callconv(hook.cc.fastcall) void) = .{}; +/// DuplicateStringWithAllocation at 0x64a620 — allocates via SMemAlloc and copies. +/// Same function SuperWoW uses to register its custom events. +const DupString = fn ([*:0]const u8, [*:0]const u8, u32) callconv(hook.cc.stdcall) u32; +const DUP_STRING: u32 = 0x64a620; +const DUP_STRING_SRC: [*:0]const u8 = "dpslog"; + +const OUR_EVENT_SLOT: u32 = 650; +const MIN_EVENT_CAPACITY: u32 = 700; +const INTERNAL_ARRAY_PTR: u32 = 0x00ceef68; +const INTERNAL_CAPACITY_PTR: u32 = 0x00ceef64; + +// ============================================================================= +// Hook: resize_lua_event_array (0x7053B0) +// __thiscall(ECX=&struct_ceef60, stack=new_count) +// Mirrors SuperWoW's check: if count > 200 (not GlueXML), expand to at least 700. +// ============================================================================= + +const EVENT_STRUCT: u32 = 0x00ceef60; + +var resize_events_hook: hook.Detour(fn (u32, u32) callconv(hook.cc.thiscall) void) = .{}; + +fn resizeEventsDetour(this: u32, new_count: u32) callconv(hook.cc.thiscall) void { + var count = new_count; + if (count > 200 and count < MIN_EVENT_CAPACITY) { + log.fmt("resizeEventsDetour: expanding {d} -> {d}\n", .{ count, MIN_EVENT_CAPACITY }); + count = MIN_EVENT_CAPACITY; + } + resize_events_hook.callOriginal(.{ this, count }); +} + fn createEventsDetour(param1: u32, max_event_id: u32) callconv(hook.cc.fastcall) void { - // Expand to at least 556 (we use slots 551..554, need maxId > 554) - var new_max = max_event_id; - if (new_max < 556) { - new_max = 556; - log.fmt("FrameScript_CreateEvents: expanded maxEventId {d} -> {d}\n", .{ max_event_id, new_max }); + create_events_hook.callOriginal(.{ param1, max_event_id }); + + if (param1 == EVENT_TABLE_MAIN) { + const capacity = hook.readMem(u32, INTERNAL_CAPACITY_PTR); + const internal_array = hook.readMem(u32, INTERNAL_ARRAY_PTR); + + if (internal_array == 0 or capacity <= OUR_EVENT_SLOT) { + log.fmt("createEventsDetour: capacity {d} too small for slot {d}\n", .{ capacity, OUR_EVENT_SLOT }); + return; + } + + // Duplicate our event name string using the engine's allocator. + const name_ptr = @call(.auto, @as(*const DupString, @ptrFromInt(DUP_STRING)), .{ event_name, DUP_STRING_SRC, 0x51c }); + + // Write into internal table entry: each entry is 16 bytes, name at +0. + hook.writeMem(internal_array + OUR_EVENT_SLOT * 16, std.mem.asBytes(&name_ptr)); + g_event_combat_log = OUR_EVENT_SLOT; + + log.fmt("createEventsDetour: COMBAT_LOG_EVENT at slot {d}, capacity={d}\n", .{ OUR_EVENT_SLOT, capacity }); } - create_events_hook.callOriginal(.{ param1, new_max }); } // ============================================================================= // Hook: SpellNonMeleeDmgLogHandler (0x5E85E0) // Packet: SMSG_SPELLNONMELEEDAMAGELOG -// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// Fires: SPELL_DAMAGE // ============================================================================= const FastCallPacketHandlerFn = fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) u32; @@ -235,10 +425,8 @@ var spell_dmg_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; fn spellNonMeleeDmgLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); - // Save read position const saved_read = cdsGetRead(cds); - // Parse SMSG_SPELLNONMELEEDAMAGELOG packet const target_guid = cdsGetPackedGuid(cds); const caster_guid = cdsGetPackedGuid(cds); const spell_id = cdsGet(u32, cds); @@ -246,46 +434,38 @@ fn spellNonMeleeDmgLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callcon const school = cdsGet(u8, cds); const absorb = cdsGet(u32, cds); const resist = cdsGet(i32, cds); - const _periodic_log = cdsGet(u8, cds); - const _unused = cdsGet(u8, cds); + _ = cdsGet(u8, cds); // periodicLog + _ = cdsGet(u8, cds); // unused const blocked = cdsGet(u32, cds); const hit_info = cdsGet(u32, cds); - // Restore read position before calling original cdsSetRead(cds, saved_read); - // Fire event if all fields parsed successfully if (target_guid != null and caster_guid != null and spell_id != null and damage != null and school != null and absorb != null and resist != null and blocked != null and hit_info != null) { - const target_str = guidToString(target_guid.?); - const caster_str = guidToString(caster_guid.?); + const src_str = guidToString(caster_guid.?); + const dst_str = guidToString(target_guid.?); + const critical: u32 = if (hit_info.? & 0x02 != 0) 1 else 0; - fireSpellDamageEvent( - EVENT_SPELL_DMG, - target_str, - caster_str, - spell_id.?, - damage.?, - @as(u32, school.?), - absorb.?, - resist.?, - blocked.?, - hit_info.?, - ); + // Auto Shot (75) and Shoot/Wand (5019) are ranged auto-attacks + const sub = if (spell_id.? == 75 or spell_id.? == 5019) SUB_RANGE_DAMAGE else SUB_SPELL_DAMAGE; + log.fmt("{s}: spell={d} amt={d} school={d} crit={d}\n", .{ + std.mem.span(sub), spell_id.?, damage.?, @as(u32, school.?), critical, + }); + fireCombatLog(sub, src_str, dst_str, spell_id.?, damage.?, @as(u32, school.?), @bitCast(resist.?), absorb.?, blocked.?, critical); + recordDamage(target_guid.?, caster_guid.?, spell_id.?); } - _ = _periodic_log; - _ = _unused; - return spell_dmg_hook.callOriginal(.{ unk, opcode, unk2, cds }); } // ============================================================================= // Hook: PeriodicAuraLogHandler (0x626DD0) // Packet: SMSG_PERIODICAURALOG -// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// Fires: SPELL_PERIODIC_DAMAGE, SPELL_PERIODIC_HEAL, SPELL_PERIODIC_ENERGIZE, +// SPELL_PERIODIC_DRAIN, SPELL_PERIODIC_LEECH // ============================================================================= var periodic_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; @@ -295,110 +475,117 @@ fn periodicAuraLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(ho const saved_read = cdsGetRead(cds); - // Parse SMSG_PERIODICAURALOG packet const target_guid = cdsGetPackedGuid(cds); const caster_guid = cdsGetPackedGuid(cds); const spell_id = cdsGet(u32, cds); const count = cdsGet(u32, cds); if (target_guid != null and caster_guid != null and spell_id != null and count != null) { - const target_str = guidToString(target_guid.?); - const caster_str = guidToString(caster_guid.?); + const src_str = guidToString(caster_guid.?); + const dst_str = guidToString(target_guid.?); - // Process first aura entry (count is almost always 1) const aura_type = cdsGet(u32, cds); if (aura_type) |at| { switch (at) { 3, 89 => { - // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT + // SPELL_PERIODIC_DAMAGE const amount = cdsGet(u32, cds) orelse 0; const spell_school = cdsGet(u32, cds) orelse 0; const absorb = cdsGet(u32, cds) orelse 0; const resist = cdsGet(i32, cds) orelse 0; - - firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, spell_school, absorb, resist, at, 0); + fireCombatLog(SUB_SPELL_PERIODIC_DAMAGE, src_str, dst_str, spell_id.?, amount, spell_school, @bitCast(resist), absorb, 0, 0); + recordDamage(target_guid.?, caster_guid.?, spell_id.?); }, 8, 20 => { - // PERIODIC_HEAL / OBS_MOD_HEALTH + // SPELL_PERIODIC_HEAL const amount = cdsGet(u32, cds) orelse 0; - - firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, 0); + fireCombatLog(SUB_SPELL_PERIODIC_HEAL, src_str, dst_str, spell_id.?, amount, 0, 0, 0, 0, 0); }, 21, 24 => { - // OBS_MOD_MANA / PERIODIC_ENERGIZE + // SPELL_PERIODIC_ENERGIZE const power_type = cdsGet(u32, cds) orelse 0; const amount = cdsGet(u32, cds) orelse 0; - - firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, power_type); + // Compute overEnergize from descriptors (before original processes the update) + var over_energize: u32 = 0; + if (target_guid != null) { + const cur = getUnitPower(target_guid.?, power_type) orelse 0; + const max = getUnitMaxPower(target_guid.?, power_type) orelse 0; + if (max > 0 and cur + amount > max) { + over_energize = cur + amount - max; + } + } + fireCombatLog(SUB_SPELL_PERIODIC_ENERGIZE, src_str, dst_str, spell_id.?, amount, over_energize, power_type, 0, 0, 0); + }, + 53 => { + // SPELL_PERIODIC_LEECH — health drain (Drain Life, Siphon Life) + // Packet format: damage, spellSchool, absorb, resist (same as periodic damage) + const amount = cdsGet(u32, cds) orelse 0; + const spell_school = cdsGet(u32, cds) orelse 0; + const absorb = cdsGet(u32, cds) orelse 0; + const resist = cdsGet(i32, cds) orelse 0; + // WotLK _LEECH suffix: amount, powerType(0=health), extraAmount(gained back) + // Vanilla doesn't send the gained amount separately — approximate as amount - absorb - resist + const effective: u32 = if (amount > absorb) amount - absorb else 0; + const gained: u32 = if (effective > @as(u32, @bitCast(resist))) effective - @as(u32, @bitCast(resist)) else 0; + fireCombatLog(SUB_SPELL_PERIODIC_LEECH, src_str, dst_str, spell_id.?, amount, spell_school, gained, absorb, @bitCast(resist), 0); + recordDamage(target_guid.?, caster_guid.?, spell_id.?); }, 64 => { - // PERIODIC_MANA_LEECH + // SPELL_PERIODIC_DRAIN — mana drain/leech (Mana Burn, Drain Mana) + // Packet format: powerType, amount, gainMultiplier const power_type = cdsGet(u32, cds) orelse 0; const amount = cdsGet(u32, cds) orelse 0; - _ = cdsGet(u32, cds); // multiplier (skip) - - firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, power_type); + const extra = cdsGet(u32, cds) orelse 0; // multiplied gain + fireCombatLog(SUB_SPELL_PERIODIC_DRAIN, src_str, dst_str, spell_id.?, amount, power_type, extra, 0, 0, 0); }, else => {}, } } } - // Restore read position cdsSetRead(cds, saved_read); - return periodic_hook.callOriginal(.{ unk, opcode, unk2, cds }); } // ============================================================================= -// Hook: SpellHealLogHandler (0x5E89C0) -// Packet: SMSG_SPELLHEALLOG (opcode 0x150) -// Convention: FastCall(unk, opCode, unk2, CDataStore*) -// Packet format: targetGuid(PackedGuid), casterGuid(PackedGuid), -// spellId(u32), healAmount(u32), isCrit(u8) +// Hook: ProcessSpellPowerDrainMessage (0x62C770) — downstream of SMSG_SPELLHEALLOG +// __fastcall(ECX=casterGuid_ptr, EDX=targetGuid_ptr, stack: spellId, healAmount, isCrit) +// RET 0xC (3 stack params). Ghidra mislabel — actually heal display function. +// Fires: SPELL_HEAL // ============================================================================= -var heal_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; +const HealDisplayFn = fn (u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) void; -fn spellHealLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { +var heal_hook: hook.Detour(HealDisplayFn) = .{}; + +fn healDisplayDetour(caster_ptr: u32, target_ptr: u32, spell_id: u32, heal_amount: u32, is_crit: u32) callconv(hook.cc.fastcall) void { asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); - const saved_read = cdsGetRead(cds); + if (caster_ptr != 0 and target_ptr != 0 and spell_id != 0) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const target_lo = hook.readMem(u32, target_ptr); + const target_hi = hook.readMem(u32, target_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + const target_guid: u64 = @as(u64, target_hi) << 32 | @as(u64, target_lo); - // Parse SMSG_SPELLHEALLOG packet - const target_guid = cdsGetPackedGuid(cds); - const caster_guid = cdsGetPackedGuid(cds); - const spell_id = cdsGet(u32, cds); - const heal_amount = cdsGet(u32, cds); - const is_crit_raw = cdsGet(u8, cds); - - cdsSetRead(cds, saved_read); - - if (target_guid != null and caster_guid != null and spell_id != null and - heal_amount != null and is_crit_raw != null) - { - const target_str = guidToString(target_guid.?); - const caster_str = guidToString(caster_guid.?); - const is_crit: u32 = if (is_crit_raw.? != 0) 1 else 0; - - fireHealEvent(EVENT_HEAL, target_str, caster_str, spell_id.?, heal_amount.?, is_crit); + if (caster_guid != 0 and target_guid != 0) { + const src_str = guidToString(caster_guid); + const dst_str = guidToString(target_guid); + const critical: u32 = if (is_crit != 0) 1 else 0; + // _HEAL: spellId, amount, overheal(0), absorbed(0), critical + fireCombatLog(SUB_SPELL_HEAL, src_str, dst_str, spell_id, heal_amount, 0, 0, critical, 0, 0); + } } - return heal_hook.callOriginal(.{ unk, opcode, unk2, cds }); + heal_hook.callOriginal(.{ caster_ptr, target_ptr, spell_id, heal_amount, is_crit }); } // ============================================================================= // Hook: MeleeDispatcher (0x6255B0) -// Shared packet handler for opcodes 0x143-0x14A. We filter for 0x14A only. +// Shared handler for opcodes 0x143-0x14A. We filter for 0x14A. // Packet: SMSG_ATTACKERSTATEUPDATE (opcode 0x14A) -// Convention: FastCall(unk, opCode, unk2, CDataStore*) -// -// Packet format: -// hitInfo(u32), attackerGuid(PackedGuid), targetGuid(PackedGuid), -// totalDamage(u32), subDamageCount(u8), -// per sub: school(u32), damageFP(f32), damage(u32), absorb(u32), resist(u32), -// victimState(u32), unknown1(u32), unknown2(u32), spellId(u32), -// if hitInfo & 0x1: blocked(u32) +// Fires: SWING_DAMAGE or SWING_MISSED // ============================================================================= const OPCODE_ATTACKERSTATEUPDATE: u32 = 0x14A; @@ -408,7 +595,6 @@ var melee_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; fn meleeDispatcherDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); - // Only intercept SMSG_ATTACKERSTATEUPDATE; pass all other opcodes through if (opcode == OPCODE_ATTACKERSTATEUPDATE) { parseMeleePacket(cds); } @@ -426,105 +612,939 @@ fn parseMeleePacket(cds: u32) void { const total_damage = cdsGet(u32, cds) orelse return; const sub_count = cdsGet(u8, cds) orelse return; - // Read first sub-damage entry for school/absorb/resist var school: u32 = 0; var absorb: u32 = 0; var resist: u32 = 0; if (sub_count > 0) { school = cdsGet(u32, cds) orelse 0; - _ = cdsGet(f32, cds); // damageFP (skip) - _ = cdsGet(u32, cds); // damage (skip, use totalDamage) + _ = cdsGet(f32, cds); // damageFP + _ = cdsGet(u32, cds); // damage (use totalDamage instead) absorb = cdsGet(u32, cds) orelse 0; resist = cdsGet(u32, cds) orelse 0; - // Skip remaining sub-damage entries var i: u8 = 1; while (i < sub_count) : (i += 1) { - _ = cdsGet(u32, cds); // school - _ = cdsGet(f32, cds); // damageFP - _ = cdsGet(u32, cds); // damage - _ = cdsGet(u32, cds); // absorb - _ = cdsGet(u32, cds); // resist + _ = cdsGet(u32, cds); + _ = cdsGet(f32, cds); + _ = cdsGet(u32, cds); + _ = cdsGet(u32, cds); + _ = cdsGet(u32, cds); } } const victim_state = cdsGet(u32, cds) orelse return; _ = cdsGet(u32, cds); // unknown1 _ = cdsGet(u32, cds); // unknown2 - _ = cdsGet(u32, cds); // spellId (0 for melee, nonzero for special attacks) + _ = cdsGet(u32, cds); // spellId var blocked: u32 = 0; if (hit_info & 0x1 != 0) { blocked = cdsGet(u32, cds) orelse 0; } - const target_str = guidToString(target_guid); - const attacker_str = guidToString(attacker_guid); + const src_str = guidToString(attacker_guid); + const dst_str = guidToString(target_guid); - // Fire melee event: same 9-arg format as spell damage - // arg1=target, arg2=attacker, arg3=totalDamage, arg4=school, - // arg5=absorb, arg6=resist, arg7=blocked, arg8=hitInfo, arg9=victimState - fireMeleeEvent(EVENT_MELEE, target_str, attacker_str, total_damage, school, absorb, resist, blocked, hit_info, victim_state); -} + // Determine miss type from victimState + const miss_type: ?[*:0]const u8 = switch (victim_state) { + VS_UNAFFECTED => if (hit_info & HITINFO_MISS != 0) MISS_MISS else null, + VS_DODGE => MISS_DODGE, + VS_PARRY => MISS_PARRY, + VS_BLOCK => MISS_BLOCK, // full block (0 damage) + VS_EVADE => MISS_EVADE, + VS_IMMUNE => MISS_IMMUNE, + VS_DEFLECT => MISS_DEFLECT, + else => null, + }; -/// Fire melee event: "%s%s%d%d%d%d%d%d%d" -/// args: targetGuid, attackerGuid, totalDamage, school, absorb, resist, blocked, hitInfo, victimState -fn fireMeleeEvent(event_id: u32, target: [*:0]const u8, attacker: [*:0]const u8, damage: u32, school: u32, absorb: u32, resist: u32, blocked: u32, hit_info: u32, victim_state: u32) void { - const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.cdecl) void; - const f: *const SignalFn = @ptrFromInt(0x703F50); - @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, attacker, damage, school, absorb, resist, blocked, hit_info, victim_state }); + if (miss_type) |mt| { + log.fmt("SWING_MISSED: vs={d} type={s}\n", .{ victim_state, std.mem.span(mt) }); + fireSwingMissed(src_str, dst_str, mt); + } else { + const critical: u32 = if (hit_info & HITINFO_CRITICALHIT != 0) 1 else 0; + // Pack glancing/crushing/offhand into flags bitmask (arg10): + // bit0=glancing, bit1=crushing, bit2=offhand + const flags: u32 = (if (hit_info & HITINFO_GLANCING != 0) @as(u32, 1) else 0) | + (if (hit_info & HITINFO_CRUSHING != 0) @as(u32, 2) else 0) | + (if (hit_info & HITINFO_LEFTSWING != 0) @as(u32, 4) else 0); + log.fmt("SWING_DAMAGE: dmg={d} crit={d} flags={d} hitInfo=0x{x}\n", .{ total_damage, critical, flags, hit_info }); + // _DAMAGE: amount, school, resisted, absorbed, blocked, critical, flags(glancing|crushing|offhand) + fireCombatLog(SUB_SWING_DAMAGE, src_str, dst_str, total_damage, school, resist, absorb, blocked, critical, flags); + recordDamage(target_guid, attacker_guid, 0); + } } // ============================================================================= -// Custom event registration — write event name ptrs to unused table slots +// Hook: ProcessEnvironmentalDamage (0x62AAC0) +// Fires: ENVIRONMENTAL_DAMAGE // ============================================================================= -fn registerCustomEvents() void { - // Each event slot's string name pointer is at EVENT_STR_PTR_BASE + eventId * 4 - // These are in .data (RW), so writeProtected is needed since the event name - // array is in .rdata/.data boundary area. +const EnvDmgFn = fn (u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) void; - const spell_dmg_addr = EVENT_STR_PTR_BASE + EVENT_SPELL_DMG * 4; - const periodic_addr = EVENT_STR_PTR_BASE + EVENT_PERIODIC * 4; - const heal_addr = EVENT_STR_PTR_BASE + EVENT_HEAL * 4; - const melee_addr = EVENT_STR_PTR_BASE + EVENT_MELEE * 4; +var env_dmg_hook: hook.Detour(EnvDmgFn) = .{}; - const spell_dmg_ptr: u32 = @intFromPtr(event_name_spell_dmg); - const periodic_ptr: u32 = @intFromPtr(event_name_periodic); - const heal_ptr: u32 = @intFromPtr(event_name_heal); - const melee_ptr: u32 = @intFromPtr(event_name_melee); +fn envDamageDetour(victim_guid_ptr: u32, damage_type: u32, damage_source: u32, base_damage: u32, absorb: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); - hook.writeProtected(spell_dmg_addr, std.mem.asBytes(&spell_dmg_ptr)); - hook.writeProtected(periodic_addr, std.mem.asBytes(&periodic_ptr)); - hook.writeProtected(heal_addr, std.mem.asBytes(&heal_ptr)); - hook.writeProtected(melee_addr, std.mem.asBytes(&melee_ptr)); + if (victim_guid_ptr != 0) { + const guid_lo = hook.readMem(u32, victim_guid_ptr); + const guid_hi = hook.readMem(u32, victim_guid_ptr + 4); + const victim_guid: u64 = @as(u64, guid_hi) << 32 | @as(u64, guid_lo); - log.fmt("Registered events: {d}={s}, {d}={s}, {d}={s}, {d}={s}\n", .{ - EVENT_SPELL_DMG, std.mem.span(event_name_spell_dmg), - EVENT_PERIODIC, std.mem.span(event_name_periodic), - EVENT_HEAL, std.mem.span(event_name_heal), - EVENT_MELEE, std.mem.span(event_name_melee), - }); + if (victim_guid != 0) { + const dst_str = guidToString(victim_guid); + const env_str = envTypeToString(damage_type); + // Env school: falling=1(physical), fire/lava=4(fire), drowning/slime=8(nature), exhausted=1 + const school: u32 = switch (damage_type) { + 1, 4 => 8, // drowning, slime → nature + 3, 5 => 4, // lava, fire → fire + else => 1, // exhausted, falling → physical + }; + log.fmt("ENVIRONMENTAL_DAMAGE: type={s} dmg={d} absorb={d}\n", .{ std.mem.span(env_str), base_damage, absorb }); + fireEnvDamage(dst_str, env_str, base_damage, school, absorb); + recordDamage(victim_guid, 0, 0); + } + } + + env_dmg_hook.callOriginal(.{ victim_guid_ptr, damage_type, damage_source, base_damage, absorb }); } +// ============================================================================= +// Helper: missInfo → string (from SpellMissInfo enum) +// ============================================================================= + +fn missInfoToString(info: u8) [*:0]const u8 { + return switch (info) { + 1 => MISS_MISS, + 2 => MISS_RESIST, + 3 => MISS_DODGE, + 4 => MISS_PARRY, + 5 => MISS_BLOCK, + 6 => MISS_EVADE, + 7, 8 => MISS_IMMUNE, + 9 => MISS_DEFLECT, + 10 => MISS_ABSORB, + 11 => MISS_REFLECT, + else => MISS_MISS, + }; +} + +// ============================================================================= +// Helper: active player GUID (for events with implicit caster) +// ============================================================================= + +fn getActivePlayerGuid() u64 { + return hook.call(fn () callconv(hook.cc.fastcall) u64, 0x468550, .{}); +} + +// ============================================================================= +// Hook: ProcessSpellCombatResult (0x62BAB0) — downstream of SMSG_SPELLLOGMISS +// __fastcall(ECX=missType, EDX=spellId, stack: casterGuidLo, casterGuidHi, +// targetGuidLo, targetGuidHi, isFromSpellLogMiss) +// RET 0x14 (5 stack params) +// Fires: SPELL_MISSED, RANGE_MISSED, SPELL_PERIODIC_MISSED, DAMAGE_SHIELD_MISSED +// ============================================================================= + +const SpellMissedFn = fn (u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) void; + +var spell_missed_hook: hook.Detour(SpellMissedFn) = .{}; + +fn spellMissedDetour(miss_type: u32, spell_id: u32, caster_lo: u32, caster_hi: u32, target_lo: u32, target_hi: u32, is_spell_log_miss: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + const target_guid: u64 = @as(u64, target_hi) << 32 | @as(u64, target_lo); + + if (caster_guid != 0 and target_guid != 0 and spell_id != 0) { + const src_str = guidToString(caster_guid); + const dst_str = guidToString(target_guid); + const miss_str = missInfoToString(@truncate(miss_type)); + const school = getSpellSchool(spell_id); + // Classify miss subevent by spell type + const sub = if (spell_id == 75 or spell_id == 5019) + SUB_RANGE_MISSED + else if (isDamageShieldSpell(spell_id)) + SUB_DAMAGE_SHIELD_MISSED + else if (isPeriodicSpell(spell_id)) + SUB_SPELL_PERIODIC_MISSED + else + SUB_SPELL_MISSED; + log.fmt("{s}: spell={d} miss={s} logmiss={d}\n", .{ std.mem.span(sub), spell_id, std.mem.span(miss_str), is_spell_log_miss }); + fireSpellStr(sub, src_str, dst_str, spell_id, school, miss_str); + } + + spell_missed_hook.callOriginal(.{ miss_type, spell_id, caster_lo, caster_hi, target_lo, target_hi, is_spell_log_miss }); +} + +// ============================================================================= +// Hook: ProcessSpellDrainEffectMessage (0x62CA20) — downstream of SMSG_SPELLDAMAGESHIELD +// __fastcall(ECX=victimGuid_ptr, EDX=casterGuid_ptr, stack: damage, school) +// RET 0x8 (2 stack params) +// Fires: DAMAGE_SHIELD +// ============================================================================= + +const DamageShieldFn = fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) void; + +var damage_shield_hook: hook.Detour(DamageShieldFn) = .{}; + +fn damageShieldDetour(victim_ptr: u32, caster_ptr: u32, damage: u32, school: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (victim_ptr != 0 and caster_ptr != 0) { + const victim_lo = hook.readMem(u32, victim_ptr); + const victim_hi = hook.readMem(u32, victim_ptr + 4); + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const victim_guid: u64 = @as(u64, victim_hi) << 32 | @as(u64, victim_lo); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + + if (victim_guid != 0 and caster_guid != 0) { + const src_str = guidToString(caster_guid); + const dst_str = guidToString(victim_guid); + log.fmt("DAMAGE_SHIELD: dmg={d} school={d}\n", .{ damage, school }); + // No spellId available in SMSG_SPELLDAMAGESHIELD — pass 0 + fireCombatLog(SUB_DAMAGE_SHIELD, src_str, dst_str, 0, damage, school, 0, 0, 0, 0); + } + } + + damage_shield_hook.callOriginal(.{ victim_ptr, caster_ptr, damage, school }); +} + +// ============================================================================= +// Hook: DisplaySpellInterruptMessage (0x626A10) — downstream of SMSG_SPELLLOGEXECUTE +// __fastcall(ECX=casterGuid_ptr, EDX=targetGuid_ptr, stack: interruptedSpellId) +// RET 0x4 (1 stack param) +// Fires: SPELL_INTERRUPT +// ============================================================================= + +const SpellInterruptFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) void; + +var spell_interrupt_hook: hook.Detour(SpellInterruptFn) = .{}; + +fn spellInterruptDetour(caster_ptr: u32, target_ptr: u32, interrupted_spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (caster_ptr != 0 and target_ptr != 0) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const target_lo = hook.readMem(u32, target_ptr); + const target_hi = hook.readMem(u32, target_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + const target_guid: u64 = @as(u64, target_hi) << 32 | @as(u64, target_lo); + + if (caster_guid != 0 and target_guid != 0 and interrupted_spell_id != 0) { + const src_str = guidToString(caster_guid); + const dst_str = guidToString(target_guid); + const extra_school = getSpellSchool(interrupted_spell_id); + log.fmt("SPELL_INTERRUPT: interrupted={d}\n", .{interrupted_spell_id}); + // _INTERRUPT: spellId(interrupt ability — unknown, pass 0), spellSchool, extraSpellId(interrupted), extraSchool + fireSpellDispel(SUB_SPELL_INTERRUPT, src_str, dst_str, 0, 0, interrupted_spell_id, extra_school, ""); + } + } + + spell_interrupt_hook.callOriginal(.{ caster_ptr, target_ptr, interrupted_spell_id }); +} + +// ============================================================================= +// Hook: ProcessInstaKillSpellMessage (0x62CBE0) — downstream of SMSG_SPELLINSTAKILLLOG +// __fastcall(ECX=casterGuid_ptr, EDX=spellId) +// Plain RET (0 stack params) +// Fires: SPELL_INSTAKILL +// ============================================================================= + +const InstaKillFn = fn (u32, u32) callconv(hook.cc.fastcall) void; + +var instakill_hook: hook.Detour(InstaKillFn) = .{}; + +fn instaKillDetour(caster_ptr: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (caster_ptr != 0 and spell_id != 0) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + + if (caster_guid != 0) { + const src_str = guidToString(caster_guid); + const school = getSpellSchool(spell_id); + log.fmt("SPELL_INSTAKILL: spell={d} caster=0x{x}\n", .{ spell_id, caster_guid }); + // No victim GUID in SMSG_SPELLINSTAKILLLOG — pass zero GUID + fireSpell(SUB_SPELL_INSTAKILL, src_str, GUID_ZERO, spell_id, school); + } + } + + instakill_hook.callOriginal(.{ caster_ptr, spell_id }); +} + +// ============================================================================= +// Hook: PartyKillLogHandler (0x628890) +// Packet: SMSG_PARTYKILLLOG (opcode 0x01F5) +// Fires: PARTY_KILL +// ============================================================================= + +var party_kill_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn partyKillLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const saved_read = cdsGetRead(cds); + + const player_guid = cdsGet(u64, cds); + const victim_guid = cdsGet(u64, cds); + + cdsSetRead(cds, saved_read); + + if (player_guid != null and victim_guid != null) { + const src_str = guidToString(player_guid.?); + const dst_str = guidToString(victim_guid.?); + log.fmt("PARTY_KILL: src=0x{x} dst=0x{x}\n", .{ player_guid.?, victim_guid.? }); + fireBase(SUB_PARTY_KILL, src_str, dst_str); + } + + return party_kill_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: SpellStartHandler (0x6E7640) +// Handles both SMSG_SPELL_START (0x0131) and SMSG_SPELL_GO (0x0132) +// Fires: SPELL_CAST_START (0x131) or SPELL_CAST_SUCCESS (0x132) +// ============================================================================= + +const OPCODE_SPELL_START: u32 = 0x131; +const OPCODE_SPELL_GO: u32 = 0x132; + +var spell_start_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn spellStartDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (opcode == OPCODE_SPELL_START or opcode == OPCODE_SPELL_GO) { + const saved_read = cdsGetRead(cds); + + // Packet: itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16) + _ = cdsGetPackedGuid(cds); // item/caster GUID (skip) + const caster_guid = cdsGetPackedGuid(cds); + const spell_id = cdsGet(u32, cds); + + if (caster_guid != null and spell_id != null) { + const src_str = guidToString(caster_guid.?); + const school = getSpellSchool(spell_id.?); + if (opcode == OPCODE_SPELL_START) { + log.fmt("SPELL_CAST_START: spell={d} caster=0x{x}\n", .{ spell_id.?, caster_guid.? }); + fireSpell(SUB_SPELL_CAST_START, src_str, GUID_ZERO, spell_id.?, school); + } else { + log.fmt("SPELL_CAST_SUCCESS: spell={d} caster=0x{x}\n", .{ spell_id.?, caster_guid.? }); + fireSpell(SUB_SPELL_CAST_SUCCESS, src_str, GUID_ZERO, spell_id.?, school); + + // Heuristic SPELL_AURA_REFRESH for all units: + // Parse SPELL_GO hit targets. If a hit target already has this aura + // in their descriptors, infer a refresh. This is best-effort — the + // server sends NO explicit refresh notification to observers in vanilla. + if (hasApplyAuraEffect(spell_id.?)) { + // Skip local player — SetActionCooldownTimer hook handles their + // refreshes reliably via SMSG_UPDATE_AURA_DURATION. + const local_guid = getActivePlayerGuid(); + _ = cdsGet(u16, cds); // castFlags + if (cdsGet(u8, cds)) |hit_count| { + var hits: u8 = 0; + while (hits < hit_count) : (hits += 1) { + const target_guid = cdsGetPackedGuid(cds) orelse break; + if (target_guid != 0 and target_guid != local_guid) { + if (unitHasAura(target_guid, spell_id.?)) |slot| { + const dst_str = guidToString(target_guid); + const aura_type = getAuraType(spell_id.?, slot); + log.fmt("SPELL_AURA_REFRESH (heuristic): spell={d} target=0x{x}\n", .{ spell_id.?, target_guid }); + fireSpellStr(SUB_SPELL_AURA_REFRESH, src_str, dst_str, spell_id.?, school, aura_type); + } + } + } + } + } + } + } + + cdsSetRead(cds, saved_read); + } + + return spell_start_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: CastResultHandler (0x6E7330) +// Packet: SMSG_CAST_RESULT (opcode 0x0130) +// Fires: SPELL_CAST_FAILED (when status != 0) +// ============================================================================= + +var cast_result_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn castResultDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const saved_read = cdsGetRead(cds); + + const spell_id = cdsGet(u32, cds); + const status = cdsGet(u8, cds); + + cdsSetRead(cds, saved_read); + + if (spell_id != null and status != null and status.? != 0) { + // SMSG_CAST_RESULT is only sent to the casting player + const player_guid = getActivePlayerGuid(); + if (player_guid != 0) { + const src_str = guidToString(player_guid); + const school = getSpellSchool(spell_id.?); + log.fmt("SPELL_CAST_FAILED: spell={d} status={d}\n", .{ spell_id.?, status.? }); + fireSpellStr(SUB_SPELL_CAST_FAILED, src_str, GUID_ZERO, spell_id.?, school, "FAILED"); + } + } + + return cast_result_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: HandleUnitDeath (0x605860) — death transition callback +// __fastcall(ECX=unitObject), plain RET (0 stack params) +// Called from HandleUnitHealthChange (0x6046F0) when newHealth < 1 && oldHealth > 0 +// Single xref — only fires on actual death transition. +// Fires: UNIT_DIED +// ============================================================================= + +const UnitDeathFn = fn (u32) callconv(hook.cc.fastcall) void; + +var unit_death_hook: hook.Detour(UnitDeathFn) = .{}; + +fn unitDeathDetour(unit_obj: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (unit_obj != 0) { + const guid_ptr = hook.readMem(u32, unit_obj + 8); + if (guid_ptr != 0) { + const guid_lo = hook.readMem(u32, guid_ptr); + const guid_hi = hook.readMem(u32, guid_ptr + 4); + const guid: u64 = @as(u64, guid_hi) << 32 | @as(u64, guid_lo); + + if (guid != 0) { + const dst_str = guidToString(guid); + log.fmt("UNIT_DIED: unit=0x{x}\n", .{guid}); + fireBase(SUB_UNIT_DIED, GUID_ZERO, dst_str); + } + } + } + + unit_death_hook.callOriginal(.{unit_obj}); +} + +// ============================================================================= +// Hook: CastSpell (0x612320) — aura removal callback +// __thiscall(ECX=unitObject, stack: slotIndex, spellId), RET 0x8 (2 stack params) +// Called from compareAndUpdateObjectArrays (0x604D00) when old aura active but new is NOT. +// Single xref — safe to hook. +// Fires: SPELL_AURA_REMOVED +// ============================================================================= + +const AuraChangeFn = fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) void; + +var aura_removed_hook: hook.Detour(AuraChangeFn) = .{}; + +fn auraRemovedDetour(unit_obj: u32, _edx: u32, slot_index: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (unit_obj != 0 and spell_id != 0) { + const guid_ptr = hook.readMem(u32, unit_obj + 8); + if (guid_ptr != 0) { + const guid_lo = hook.readMem(u32, guid_ptr); + const guid_hi = hook.readMem(u32, guid_ptr + 4); + const guid: u64 = @as(u64, guid_hi) << 32 | @as(u64, guid_lo); + + if (guid != 0) { + const dst_str = guidToString(guid); + const aura_type: [*:0]const u8 = getAuraType(spell_id, slot_index); + const school = getSpellSchool(spell_id); + + // Check if this aura was broken by damage (SPELL_AURA_BROKEN heuristic). + // If the spell has damage-break flags and the target was recently damaged, + // fire SPELL_AURA_BROKEN_SPELL (spell break) or SPELL_AURA_BROKEN (melee break). + if (getSpellRecord(spell_id)) |rec| { + const aura_int_flags = hook.readMem(u32, rec + SPELL_AURA_INTERRUPT_FLAGS_OFFSET); + if (aura_int_flags & (AURA_INTERRUPT_FLAG_DAMAGE | AURA_INTERRUPT_FLAG_DIRECT_DAMAGE) != 0) { + if (findRecentDamage(guid)) |dmg| { + const src_str = if (dmg.source_guid != 0) guidToString(dmg.source_guid) else GUID_ZERO; + if (dmg.spell_id != 0) { + // Spell broke the aura — WotLK arg order: broken aura as primary, breaker as extra + const dmg_school = getSpellSchool(dmg.spell_id); + log.fmt("SPELL_AURA_BROKEN_SPELL: aura={d} by spell={d} src=0x{x}\n", .{ spell_id, dmg.spell_id, dmg.source_guid }); + fireSpellDispel(SUB_SPELL_AURA_BROKEN_SPELL, src_str, dst_str, spell_id, school, dmg.spell_id, dmg_school, aura_type); + } else { + // Melee (swing) broke the aura — no breaking spell ID + log.fmt("SPELL_AURA_BROKEN: aura={d} by melee src=0x{x}\n", .{ spell_id, dmg.source_guid }); + fireSpellStr(SUB_SPELL_AURA_BROKEN, src_str, dst_str, spell_id, school, aura_type); + } + } + } + } + + // Always fire SPELL_AURA_REMOVED (even if broken — WotLK fires both) + log.fmt("SPELL_AURA_REMOVED: spell={d} slot={d} unit=0x{x}\n", .{ spell_id, slot_index, guid }); + fireSpellStr(SUB_SPELL_AURA_REMOVED, GUID_ZERO, dst_str, spell_id, school, aura_type); + } + } + } + + aura_removed_hook.callOriginal(.{ unit_obj, _edx, slot_index, spell_id }); +} + +// ============================================================================= +// Hook: SetSpellTarget (0x6123F0) — aura application callback +// __thiscall(ECX=unitObject, stack: slotIndex, spellId), RET 0x8 (2 stack params) +// Called from compareAndUpdateObjectArrays (0x604D00) when new aura active but old was NOT. +// Single xref — safe to hook. +// Fires: SPELL_AURA_APPLIED +// ============================================================================= + +var aura_applied_hook: hook.Detour(AuraChangeFn) = .{}; + +fn auraAppliedDetour(unit_obj: u32, _edx: u32, slot_index: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (unit_obj != 0 and spell_id != 0) { + const guid_ptr = hook.readMem(u32, unit_obj + 8); + if (guid_ptr != 0) { + const guid_lo = hook.readMem(u32, guid_ptr); + const guid_hi = hook.readMem(u32, guid_ptr + 4); + const guid: u64 = @as(u64, guid_hi) << 32 | @as(u64, guid_lo); + + if (guid != 0) { + const dst_str = guidToString(guid); + const aura_type: [*:0]const u8 = getAuraType(spell_id, slot_index); + const school = getSpellSchool(spell_id); + log.fmt("SPELL_AURA_APPLIED: spell={d} slot={d} unit=0x{x}\n", .{ spell_id, slot_index, guid }); + fireSpellStr(SUB_SPELL_AURA_APPLIED, GUID_ZERO, dst_str, spell_id, school, aura_type); + } + } + } + + aura_applied_hook.callOriginal(.{ unit_obj, _edx, slot_index, spell_id }); +} + +// ============================================================================= +// Hook: ValidateSpellSlot (0x612450) — aura stack count change +// __thiscall(ECX=unitObject, stack: slotIndex, requestedLevel), RET 0x8 (2 stack params) +// Called from updateObjectWithByteValue (0x604EA0) for EVERY aura count change. +// requestedLevel = OLD count (from saved data), live descriptor has NEW count. +// Fires: SPELL_AURA_APPLIED_DOSE (increase) or SPELL_AURA_REMOVED_DOSE (decrease) +// ============================================================================= + +var aura_dose_hook: hook.Detour(AuraChangeFn) = .{}; + +fn auraDoseDetour(unit_obj: u32, _edx: u32, slot_index: u32, old_count_raw: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (unit_obj != 0 and slot_index < 48) { + const m_data = hook.readMem(u32, unit_obj + 0x110); + if (m_data != 0) { + const old_count: u8 = @truncate(old_count_raw); + const new_count = hook.readMem(u8, m_data + 0x1AC + slot_index); + + if (old_count != new_count) { + // Spell ID from UNIT_FIELD_AURA array (offset 0xA4, 4 bytes per slot) + const spell_id = hook.readMem(u32, m_data + 0xA4 + slot_index * 4); + if (spell_id != 0) { + const guid_ptr = hook.readMem(u32, unit_obj + 8); + if (guid_ptr != 0) { + const guid_lo = hook.readMem(u32, guid_ptr); + const guid_hi = hook.readMem(u32, guid_ptr + 4); + const guid: u64 = @as(u64, guid_hi) << 32 | @as(u64, guid_lo); + + if (guid != 0) { + const dst_str = guidToString(guid); + const aura_type: [*:0]const u8 = getAuraType(spell_id, slot_index); + const school = getSpellSchool(spell_id); + + if (new_count > old_count) { + log.fmt("SPELL_AURA_APPLIED_DOSE: spell={d} slot={d} count={d}\n", .{ spell_id, slot_index, new_count }); + fireSpellStrD(SUB_SPELL_AURA_APPLIED_DOSE, GUID_ZERO, dst_str, spell_id, school, aura_type, new_count); + } else { + log.fmt("SPELL_AURA_REMOVED_DOSE: spell={d} slot={d} count={d}\n", .{ spell_id, slot_index, new_count }); + fireSpellStrD(SUB_SPELL_AURA_REMOVED_DOSE, GUID_ZERO, dst_str, spell_id, school, aura_type, new_count); + } + } + } + } + } + } + } + + aura_dose_hook.callOriginal(.{ unit_obj, _edx, slot_index, old_count_raw }); +} + +// ============================================================================= +// Hook: ProcessExtraAttacksSpellMessage (0x62D9F0) — extra attacks notification +// __fastcall(ECX=casterGUID_ptr, EDX=unused, stack: spellId), RET 0x4 (1 stack param) +// Called from SPELLLOGEXECUTE handler for effectType 19 (EXTRA_ATTACKS in vanilla). +// Fires: SPELL_EXTRA_ATTACKS +// ============================================================================= + +const ExtraAttacksFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) void; + +var extra_attacks_hook: hook.Detour(ExtraAttacksFn) = .{}; + +fn extraAttacksDetour(caster_ptr: u32, _edx: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (caster_ptr != 0 and spell_id != 0) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + + if (caster_guid != 0) { + const src_str = guidToString(caster_guid); + const school = getSpellSchool(spell_id); + log.fmt("SPELL_EXTRA_ATTACKS: spell={d}\n", .{spell_id}); + // _EXTRA_ATTACKS: spellId, spellSchool, amount(0 — not available from downstream) + fireCombatLog(SUB_SPELL_EXTRA_ATTACKS, src_str, GUID_ZERO, spell_id, school, 0, 0, 0, 0, 0); + } + } + + extra_attacks_hook.callOriginal(.{ caster_ptr, _edx, spell_id }); +} + +// ============================================================================= +// Hook: ProcessAuraDispelMessage (0x62D480) — aura dispel notification +// __fastcall(ECX=casterGUID_ptr, EDX=targetGUID_ptr, stack: dispelledSpellId), RET 0x4 +// Called from SMSG_SPELLDISPELLOG handler (0x5E8B60) per dispelled aura. +// Fires: SPELL_DISPEL +// ============================================================================= + +var dispel_hook: hook.Detour(ExtraAttacksFn) = .{}; // Same fn type: fastcall(ptr, ptr, u32) + +fn dispelDetour(caster_ptr: u32, target_ptr: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (caster_ptr != 0 and target_ptr != 0 and spell_id != 0) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const target_lo = hook.readMem(u32, target_ptr); + const target_hi = hook.readMem(u32, target_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + const target_guid: u64 = @as(u64, target_hi) << 32 | @as(u64, target_lo); + + if (caster_guid != 0 and target_guid != 0) { + const src_str = guidToString(caster_guid); + const dst_str = guidToString(target_guid); + const extra_school = getSpellSchool(spell_id); + // Determine aura type from spell DB for the dispelled aura + const aura_type_str: [*:0]const u8 = blk: { + if (getSpellRecord(spell_id)) |rec| { + const attrs = hook.readMem(u32, rec + 0x18); + if (attrs & 0x4000000 != 0) break :blk @as([*:0]const u8, "DEBUFF"); + } + break :blk @as([*:0]const u8, "BUFF"); + }; + log.fmt("SPELL_DISPEL: dispelled={d}\n", .{spell_id}); + // _DISPEL: dispelSpellId(0 — unknown), school, extraSpellId(dispelled aura), extraSchool, auraType + fireSpellDispel(SUB_SPELL_DISPEL, src_str, dst_str, 0, 0, spell_id, extra_school, aura_type_str); + } + } + + dispel_hook.callOriginal(.{ caster_ptr, target_ptr, spell_id }); +} + +// ============================================================================= +// Spell DB lookup — WowClientDB at 0xC0D780 +// ============================================================================= +// +// Structure: m_records(+0), m_numRecords(+4), m_recordsById(+8), m_maxId(+C), m_loaded(+10) +// SpellRec: Id(+0), School(+4), Effect[3](+F4/+F8/+FC) +// Access: *(*(0xC0D788) + spellId * 4) → SpellRec* (from nampower reference) + +const SPELL_DB_RECORDS: u32 = 0xC0D788; // m_recordsById pointer +const SPELL_DB_MAX_ID: u32 = 0xC0D78C; // m_maxId + +fn getSpellRecord(spell_id: u32) ?u32 { + if (spell_id == 0) return null; + const max_id = hook.readMem(u32, SPELL_DB_MAX_ID); + if (spell_id > max_id) return null; + const records_ptr = hook.readMem(u32, SPELL_DB_RECORDS); + if (records_ptr == 0) return null; + const rec = hook.readMem(u32, records_ptr + spell_id * 4); + if (rec == 0) return null; + return rec; +} + +/// Look up spell school bitmask from spell DB. +fn getSpellSchool(spell_id: u32) u32 { + if (getSpellRecord(spell_id)) |rec| { + return hook.readMem(u32, rec + 0x04); + } + return 0; +} + +/// Read a unit's current power value from descriptors. +/// power_type: 0=mana, 1=rage, 2=focus, 3=energy, 4=combo +fn getUnitPower(guid: u64, power_type: u32) ?u32 { + if (power_type > 4) return null; + const obj = wow.getObjectByGUID(guid); + if (obj == 0) return null; + const m_data = hook.readMem(u32, obj + 0x08); + if (m_data == 0) return null; + return hook.readMem(u32, m_data + DESC_POWER_BASE + power_type * 4); +} + +/// Read a unit's max power value from descriptors. +fn getUnitMaxPower(guid: u64, power_type: u32) ?u32 { + if (power_type > 4) return null; + const obj = wow.getObjectByGUID(guid); + if (obj == 0) return null; + const m_data = hook.readMem(u32, obj + 0x08); + if (m_data == 0) return null; + return hook.readMem(u32, m_data + DESC_MAXPOWER_BASE + power_type * 4); +} + +/// Vanilla spell effect types for summon abilities. +fn isSummonEffect(e: u32) bool { + return e == 28 or e == 42 or e == 56 or + (e >= 85 and e <= 88) or (e >= 104 and e <= 107) or e == 109; +} + +/// Vanilla spell effect types for resurrect abilities. +fn isResurrectEffect(e: u32) bool { + return e == 18 or e == 113; +} + +/// Vanilla spell effect type for energize. +fn isEnergizeEffect(e: u32) bool { + return e == 30; +} + +/// Determine aura type from spell DB Attributes flag. +/// Spell attribute 0x4000000 = SPELL_ATTR_NEGATIVE (harmful/debuff). +/// Falls back to slot-based heuristic if spell not found in DB. +fn getAuraType(spell_id: u32, slot_index: u32) [*:0]const u8 { + if (getSpellRecord(spell_id)) |rec| { + const attrs = hook.readMem(u32, rec + 0x18); + if (attrs & 0x4000000 != 0) return "DEBUFF"; + return "BUFF"; + } + // Fallback: slots 0-39 = buffs, 40-47 = debuffs + return if (slot_index < 40) "BUFF" else "DEBUFF"; +} + +/// Check if spell has any APPLY_AURA (6) or APPLY_AREA_AURA (27) effect. +fn hasApplyAuraEffect(spell_id: u32) bool { + if (getSpellRecord(spell_id)) |rec| { + const e0 = hook.readMem(u32, rec + 0xF4); + const e1 = hook.readMem(u32, rec + 0xF8); + const e2 = hook.readMem(u32, rec + 0xFC); + return e0 == 6 or e0 == 27 or e1 == 6 or e1 == 27 or e2 == 6 or e2 == 27; + } + return false; +} + +/// Check if spell has any periodic aura effect (DoT, HoT, periodic energize/drain/leech). +/// Checks EffectApplyAuraName[0..2] at offsets 0x16C..0x174 for periodic aura types. +fn isPeriodicSpell(spell_id: u32) bool { + const rec = getSpellRecord(spell_id) orelse return false; + const periodic_auras = [_]u32{ 3, 8, 20, 21, 24, 53, 64, 89 }; + inline for (0..3) |i| { + const effect = hook.readMem(u32, rec + 0xF4 + i * 4); + if (effect == 6 or effect == 27) { + const aura = hook.readMem(u32, rec + 0x16C + i * 4); + inline for (periodic_auras) |pa| { + if (aura == pa) return true; + } + } + } + return false; +} + +/// Check if spell has a SPELL_AURA_DAMAGE_SHIELD (26) effect. +fn isDamageShieldSpell(spell_id: u32) bool { + const rec = getSpellRecord(spell_id) orelse return false; + inline for (0..3) |i| { + const effect = hook.readMem(u32, rec + 0xF4 + i * 4); + if (effect == 6 or effect == 27) { + const aura = hook.readMem(u32, rec + 0x16C + i * 4); + if (aura == 26) return true; + } + } + return false; +} + +/// Check if a unit already has a specific aura spell. Returns slot index if found. +fn unitHasAura(guid: u64, spell_id: u32) ?u32 { + const obj = wow.getObjectByGUID(guid); + if (obj == 0) return null; + const m_data = hook.readMem(u32, obj + 0x8); + if (m_data == 0) return null; + + for (0..48) |i| { + const slot: u32 = @intCast(i); + const sid = hook.readMem(u32, m_data + 0xA4 + slot * 4); + if (sid == spell_id) { + const flags_byte = hook.readMem(u8, m_data + 0x164 + slot / 2); + const nibble: u8 = if (slot % 2 == 0) flags_byte & 0x0F else flags_byte >> 4; + if ((nibble & 0xE) != 0) return slot; + } + } + return null; +} + +// ============================================================================= +// Hook: ProcessSpellEffect (0x62ACE0) — generic spell effect display +// __fastcall(ECX=casterGUID_ptr, EDX=targetGUID_ptr, stack: spellId), RET 0x4 +// Called from SPELLLOGEXECUTE for many effect types (cases 0, 2, 10/default). +// effectType 30 (ENERGIZE) falls through to default case → this function. +// We filter by spell DB Effect[] lookup to detect SUMMON, RESURRECT, and ENERGIZE. +// Fires: SPELL_SUMMON, SPELL_RESURRECT, SPELL_ENERGIZE +// ============================================================================= + +var spell_effect_hook: hook.Detour(ExtraAttacksFn) = .{}; + +fn spellEffectDetour(caster_ptr: u32, target_ptr: u32, spell_id: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (caster_ptr != 0 and spell_id != 0) { + if (getSpellRecord(spell_id)) |rec| { + const e0 = hook.readMem(u32, rec + 0xF4); + const e1 = hook.readMem(u32, rec + 0xF8); + const e2 = hook.readMem(u32, rec + 0xFC); + + const is_summon = isSummonEffect(e0) or isSummonEffect(e1) or isSummonEffect(e2); + const is_resurrect = isResurrectEffect(e0) or isResurrectEffect(e1) or isResurrectEffect(e2); + const is_energize = isEnergizeEffect(e0) or isEnergizeEffect(e1) or isEnergizeEffect(e2); + + if (is_summon or is_resurrect or is_energize) { + const caster_lo = hook.readMem(u32, caster_ptr); + const caster_hi = hook.readMem(u32, caster_ptr + 4); + const caster_guid: u64 = @as(u64, caster_hi) << 32 | @as(u64, caster_lo); + + if (caster_guid != 0) { + const src_str = guidToString(caster_guid); + const school = hook.readMem(u32, rec + 0x04); + + // Target may be NULL for summons (SPELLLOGEXECUTE case 10 passes NULL) + var dst_str: [*:0]const u8 = GUID_ZERO; + if (target_ptr != 0) { + const target_lo = hook.readMem(u32, target_ptr); + const target_hi = hook.readMem(u32, target_ptr + 4); + const target_guid: u64 = @as(u64, target_hi) << 32 | @as(u64, target_lo); + if (target_guid != 0) { + dst_str = guidToString(target_guid); + } + } + + if (is_summon) { + log.fmt("SPELL_SUMMON: spell={d}\n", .{spell_id}); + fireSpell(SUB_SPELL_SUMMON, src_str, dst_str, spell_id, school); + } + if (is_resurrect) { + log.fmt("SPELL_RESURRECT: spell={d}\n", .{spell_id}); + fireSpell(SUB_SPELL_RESURRECT, src_str, dst_str, spell_id, school); + } + if (is_energize) { + log.fmt("SPELL_ENERGIZE: spell={d}\n", .{spell_id}); + // _ENERGIZE: spellId, amount(0 — not available from SPELLLOGEXECUTE), powerType(0 — unknown) + fireCombatLog(SUB_SPELL_ENERGIZE, src_str, dst_str, spell_id, 0, 0, 0, 0, 0, 0); + } + } + } + } + } + + spell_effect_hook.callOriginal(.{ caster_ptr, target_ptr, spell_id }); +} + +// ============================================================================= +// TODO: Remaining hooks (require deep aura system research) +// ============================================================================= +// Hook: SetActionCooldownTimer (0x4E4390) — aura duration update +// __fastcall(ECX=auraSlot: u8, EDX=duration_ms: u32) +// Called from SMSG_UPDATE_AURA_DURATION (opcode 0x137) handler. +// This packet fires on both initial application and refresh. +// On initial application: descriptor update (SMSG_UPDATE_OBJECT) hasn't arrived yet, +// so the aura slot is still empty → we skip (SPELL_AURA_APPLIED handles it). +// On refresh: the slot already has an active aura from the earlier application → +// we detect it and fire SPELL_AURA_REFRESH. +// +// ⚠ LOCAL PLAYER ONLY — vanilla sends SMSG_UPDATE_AURA_DURATION only to the +// buffed player. Other units' aura refreshes are invisible: the descriptor spell ID +// doesn't change, and no packet is sent to observers. WotLK fixed this with +// SMSG_AURA_UPDATE which carries full aura state for all units. +// To cover all units, a deeper approach is needed — e.g. hooking inside +// compareAndUpdateObjectArrays (0x604D00) to intercept descriptor writes before +// the diff comparison, or shadowing the aura slot table to detect same-ID overwrites. +// +// Fires: SPELL_AURA_REFRESH (local player only) +// ============================================================================= + +const AuraDurationFn = fn (u32, u32) callconv(hook.cc.fastcall) void; + +var aura_duration_hook: hook.Detour(AuraDurationFn) = .{}; + +fn auraDurationDetour(slot: u32, duration: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + if (slot < 48) { + // Get local player object + const player_guid = getActivePlayerGuid(); + if (player_guid != 0) { + const player_obj = wow.getObjectByGUID(player_guid); + + if (player_obj != 0) { + // Read descriptor data pointer: object+0x8 = descriptor base + const m_data = hook.readMem(u32, player_obj + 0x8); + if (m_data != 0) { + // UNIT_FIELD_AURA starts at descriptor offset 0xA4, each slot is 4 bytes + const spell_id = hook.readMem(u32, m_data + 0xA4 + slot * 4); + + // UNIT_FIELD_AURAFLAGS: packed nibbles (4 bits per slot) at offset 0x164 + // Each byte covers 2 slots: even slot = low nibble, odd slot = high nibble + const flags_byte = hook.readMem(u8, m_data + 0x164 + slot / 2); + const nibble: u8 = if (slot % 2 == 0) flags_byte & 0x0F else flags_byte >> 4; + const is_active = spell_id != 0 and (nibble & 0xE) != 0; + + if (is_active) { + // Slot already has this aura — this is a refresh + const dst_str = guidToString(player_guid); + const aura_type = getAuraType(spell_id, slot); + const school = getSpellSchool(spell_id); + log.fmt("SPELL_AURA_REFRESH: spell={d} slot={d}\n", .{ spell_id, slot }); + fireSpellStr(SUB_SPELL_AURA_REFRESH, GUID_ZERO, dst_str, spell_id, school, aura_type); + } + } + } + } + } + + aura_duration_hook.callOriginal(.{ slot, duration }); +} + +// ============================================================================= +// +// SPELL_AURA_BROKEN: CC break path — deep aura system research needed +// RANGE_MISSED already handled via spell ID check in SPELL_MISSED hook + +// Event registration now happens dynamically in createEventsDetour. + // ============================================================================= // Install / remove // ============================================================================= pub fn installHooks() void { - const result = mod_mutex.acquire(module_name); g_mutex = result.handle; g_is_hook_owner = result.is_owner; if (!g_is_hook_owner) return; - log = logging.Logger.open(module_name, .console); + log = logging.Logger.open(module_name, .both); - // Register custom events (overwrite event name string pointers) - registerCustomEvents(); + if (resize_events_hook.attach(0x7053B0, &resizeEventsDetour) != .ok) { + log.print("FAILED to hook resize_lua_event_array\n"); + } else { + log.print("Hooked resize_lua_event_array\n"); + } - // Hook FrameScript_CreateEvents to expand event count if (create_events_hook.attach(0x703D90, &createEventsDetour) != .ok) { log.print("FAILED to hook FrameScript_CreateEvents\n"); } else { @@ -543,10 +1563,10 @@ pub fn installHooks() void { log.print("Hooked PeriodicAuraLogHandler\n"); } - if (heal_hook.attach(0x5E89C0, &spellHealLogDetour) != .ok) { - log.print("FAILED to hook SpellHealLogHandler\n"); + if (heal_hook.attach(0x62C770, &healDisplayDetour) != .ok) { + log.print("FAILED to hook ProcessSpellPowerDrainMessage\n"); } else { - log.print("Hooked SpellHealLogHandler\n"); + log.print("Hooked ProcessSpellPowerDrainMessage (SPELL_HEAL)\n"); } if (melee_hook.attach(0x6255B0, &meleeDispatcherDetour) != .ok) { @@ -554,15 +1574,128 @@ pub fn installHooks() void { } else { log.print("Hooked MeleeDispatcher\n"); } + + if (env_dmg_hook.attach(0x62AAC0, &envDamageDetour) != .ok) { + log.print("FAILED to hook ProcessEnvironmentalDamage\n"); + } else { + log.print("Hooked ProcessEnvironmentalDamage\n"); + } + + if (party_kill_hook.attach(0x628890, &partyKillLogDetour) != .ok) { + log.print("FAILED to hook PartyKillLogHandler\n"); + } else { + log.print("Hooked PartyKillLogHandler\n"); + } + + if (spell_start_hook.attach(0x6E7640, &spellStartDetour) != .ok) { + log.print("FAILED to hook SpellStartHandler\n"); + } else { + log.print("Hooked SpellStartHandler\n"); + } + + if (cast_result_hook.attach(0x6E7330, &castResultDetour) != .ok) { + log.print("FAILED to hook CastResultHandler\n"); + } else { + log.print("Hooked CastResultHandler\n"); + } + + if (spell_missed_hook.attach(0x62BAB0, &spellMissedDetour) != .ok) { + log.print("FAILED to hook ProcessSpellCombatResult\n"); + } else { + log.print("Hooked ProcessSpellCombatResult (SPELL_MISSED)\n"); + } + + if (damage_shield_hook.attach(0x62CA20, &damageShieldDetour) != .ok) { + log.print("FAILED to hook ProcessSpellDrainEffectMessage\n"); + } else { + log.print("Hooked ProcessSpellDrainEffectMessage (DAMAGE_SHIELD)\n"); + } + + if (spell_interrupt_hook.attach(0x626A10, &spellInterruptDetour) != .ok) { + log.print("FAILED to hook DisplaySpellInterruptMessage\n"); + } else { + log.print("Hooked DisplaySpellInterruptMessage (SPELL_INTERRUPT)\n"); + } + + if (instakill_hook.attach(0x62CBE0, &instaKillDetour) != .ok) { + log.print("FAILED to hook ProcessInstaKillSpellMessage\n"); + } else { + log.print("Hooked ProcessInstaKillSpellMessage (SPELL_INSTAKILL)\n"); + } + + if (unit_death_hook.attach(0x605860, &unitDeathDetour) != .ok) { + log.print("FAILED to hook HandleUnitDeath\n"); + } else { + log.print("Hooked HandleUnitDeath (UNIT_DIED)\n"); + } + + if (aura_removed_hook.attach(0x612320, &auraRemovedDetour) != .ok) { + log.print("FAILED to hook CastSpell\n"); + } else { + log.print("Hooked CastSpell (SPELL_AURA_REMOVED)\n"); + } + + if (aura_applied_hook.attach(0x6123F0, &auraAppliedDetour) != .ok) { + log.print("FAILED to hook SetSpellTarget\n"); + } else { + log.print("Hooked SetSpellTarget (SPELL_AURA_APPLIED)\n"); + } + + if (aura_dose_hook.attach(0x612450, &auraDoseDetour) != .ok) { + log.print("FAILED to hook ValidateSpellSlot\n"); + } else { + log.print("Hooked ValidateSpellSlot (SPELL_AURA_*_DOSE)\n"); + } + + if (extra_attacks_hook.attach(0x62D9F0, &extraAttacksDetour) != .ok) { + log.print("FAILED to hook ProcessExtraAttacksSpellMessage\n"); + } else { + log.print("Hooked ProcessExtraAttacksSpellMessage (SPELL_EXTRA_ATTACKS)\n"); + } + + if (dispel_hook.attach(0x62D480, &dispelDetour) != .ok) { + log.print("FAILED to hook ProcessAuraDispelMessage\n"); + } else { + log.print("Hooked ProcessAuraDispelMessage (SPELL_DISPEL)\n"); + } + + if (spell_effect_hook.attach(0x62ACE0, &spellEffectDetour) != .ok) { + log.print("FAILED to hook ProcessSpellEffect\n"); + } else { + log.print("Hooked ProcessSpellEffect (SPELL_SUMMON/RESURRECT/ENERGIZE)\n"); + } + + if (aura_duration_hook.attach(0x4E4390, &auraDurationDetour) != .ok) { + log.print("FAILED to hook SetActionCooldownTimer\n"); + } else { + log.print("Hooked SetActionCooldownTimer (SPELL_AURA_REFRESH)\n"); + } } pub fn removeHooks() void { if (g_is_hook_owner) { + aura_duration_hook.detach(); + spell_effect_hook.detach(); + dispel_hook.detach(); + extra_attacks_hook.detach(); + aura_dose_hook.detach(); + aura_applied_hook.detach(); + aura_removed_hook.detach(); + unit_death_hook.detach(); + instakill_hook.detach(); + spell_interrupt_hook.detach(); + damage_shield_hook.detach(); + spell_missed_hook.detach(); + cast_result_hook.detach(); + spell_start_hook.detach(); + party_kill_hook.detach(); + env_dmg_hook.detach(); melee_hook.detach(); heal_hook.detach(); periodic_hook.detach(); spell_dmg_hook.detach(); create_events_hook.detach(); + resize_events_hook.detach(); log.close(); mod_mutex.release(&g_mutex); }