Compare commits

...

2 Commits

Author SHA1 Message Date
Docker Build 7c57abd69f Paragon: weapon-class subclass bypass for proc talents (Maelstrom Weapon any weapon)
Cross-class wildcard now also relaxes the EquippedItemSubClassMask
gate on weapon-class proc talents so e.g. Maelstrom Weapon procs
from any weapon a Paragon has equipped, not just the talent's stock
melee subset (axe / mace / staff / fist / dagger / 2H sword/axe/mace).

Three independent gates run in the proc chain; all three needed the
bypass for the proc to actually fire:

- Player::HasItemFitToSpellRequirements -- talent-attach check at
  item-equip / login time. Without this the passive talent aura
  never even applies for a Paragon wielding a non-stock weapon.
- Player::CheckAttackFitToAuraRequirement -- per-swing match the
  proc engine uses to decide whether attackType + weapon is
  compatible with the aura's EquippedItemClass / SubClassMask.
- Aura::IsProcTriggeredOnEvent (SpellAuras.cpp) -- per-event proc
  evaluator that calls Item::IsFitToSpellRequirements again,
  independently of the previous two. Was the proc-killing gate
  before this commit: talent attached, swing matched, but this
  evaluator returned 0 charges for any weapon outside the stock
  subclass mask, so no stack was ever applied.

All three bypasses are gated on:
- IsParagonWildcardCaller(this) (player class 12 + config flag
  Paragon.WildcardFamilyMatching = 1).
- spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON. ARMOR-class
  gates (shield) are deliberately left alone -- shield-required
  talents (Shield Specialization, Shield Block, etc.) still need
  an actual shield equipped.

Each bypass also requires *some* weapon to be in the relevant slot
(MAINHAND / OFFHAND / RANGED). Unarmed Paragons do not auto-activate
every weapon-gated talent in the game.

Net effect for Paragon characters with Maelstrom Weapon talented:
proc fires from any melee weapon -- 1H sword / polearm / spear /
fist / dagger / staff / 2H weapons / axes / maces. Stock Shamans
and every other non-Paragon class are unchanged.

Caveat (not addressed by this commit): the talent's stock ProcFlags
(0xC00014) only fire on melee swings + melee abilities. Ranged
auto-attacks (bow / gun / crossbow / wand) fire
PROC_FLAG_DONE_RANGED_AUTO_ATTACK (0x40), which the proc engine
never matches against this talent, so the new weapon-subclass
bypass is moot for those weapons. Adding ranged-auto-attack support
would require a Paragon-only ProcFlags expansion at the proc
engine layer; deferred until requested.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:19:42 -04:00
Docker Build e649402163 Paragon: cross-class talents + Warrior stance bypass + Mirror Image spellbook draw
Server-side cross-class wildcard pass for several talents that were
previously locked to a single SpellFamilyName, plus a server+client
Warrior stance bypass and a Paragon-aware Mirror Image rebuild that
mimics the owner's spellbook instead of stock Frostbolt/Fire Blast.

Talent expansions (Paragon owners only; stock classes unchanged):
- Cold Snap (11958): resets cooldown of any Frost-school spell.
- Nature's Swiftness (17116, 16188) + Predator's Swiftness (69369):
  instant-cast on any Nature-school spell.
- Vampiric Embrace (15286): leech-heals from any single-target
  Shadow-school spell.
- Fingers of Frost (44543/44545) + Frostbite (11071/12496/12497):
  proc from any Frost-school chill effect (DK Howling Blast / Icy
  Touch / Chains of Ice, Hunter Frost Trap, Shaman Frost Shock,
  cross-class chill auras via SPELL_AURA_MOD_DECREASE_SPEED).
- Maelstrom Weapon (53817): now also affects Mage Fireball (133),
  Frostbolt (116), and Arcane Blast (30451) at every rank, both
  for cast-time/cost spellmod and for stack consumption.

Warrior stance bypass:
- SpellInfo::CheckShapeshift returns SPELL_CAST_OK whenever a
  Paragon caster hits any Stances!=0 spell (no SpellFamilyName
  gate). Stock classes still see the regular form rules.
- Client side: patch-enUS-4.MPQ now zeroes Stances on every
  SPELLFAMILY_WARRIOR Spell.dbc row (105 spells) so the engine's
  pre-cast "Must be in Battle/Defensive/Berserker Stance" check
  no longer eats CMSG_CAST_SPELL packets for Paragons. Server
  bypass enforces the actual decision; stock Warriors still
  error mid-cast if they actually click while out of stance.
- patch-enUS-5.MPQ Lua tooltip post-processor recolors and
  appends "(Paragon: bypassed)" to "Requires *Stance*" lines on
  Warrior abilities, plus Paragon notes on Maelstrom Weapon and
  Mirror Image tooltips. Action-bar UseAction wrapper routes
  stance-gated Warrior spell clicks through CastSpellByName so
  the stance-zero DBC + server bypass actually run.

Mirror Image:
- npc_pet_mage_mirror_image rebuilds its spell list from the
  Paragon owner's spellbook on InitializeAI AND JustEngagedWith
  (the second pass + events.Reset clears any stale events the
  CasterAI base scheduler may have queued from stock 59637 /
  59638 entries before the rebuild ran).
- Curated filter keeps single-target damaging spells (instant,
  cast-time, or channeled) with a base cooldown <=10s, with the
  "damaging" definition expanded to include
  SPELL_EFFECT_TRIGGER_MISSILE and
  SPELL_AURA_PERIODIC_TRIGGER_SPELL so Arcane Missiles
  qualifies. Rejects passives, AoE, melee/ranged weapon strikes,
  item/reagent/stance/equip-gated, and lower spell ranks.
- UpdateAI picks a random spell from the curated list per cast
  and reschedules the next pick by the actually-cast spell's
  cast/channel duration + 750ms breather, so a 5s Arcane
  Missiles channel waits its full duration before re-rolling
  rather than visually looping across four images.

Helpers:
- Unit::IsParagonWildcardCaller / Unit::ParagonFamilyMatches
  used by Spell.cpp, SpellInfo.cpp, Player.cpp,
  SpellAuraEffects.cpp, and the spell scripts.
- SpellInfo::CheckShapeshift signature gains an optional caster
  pointer; all call sites updated.

SQL migrations under modules/mod-paragon/data/sql/db-world/updates/:
- 2026_05_11_01.sql  Vampiric Embrace spell_proc relax + script
                     gate (CheckProc enforces stock for non-Paragon).
- 2026_05_11_02.sql  Maelstrom Weapon spell_proc relax (initial,
                     superseded by _04 below for stack-consumption fix).
- 2026_05_11_03.sql  Fingers of Frost / Frostbite spell_proc relax
                     and spell_script_names binding.
- 2026_05_11_04.sql  Maelstrom Weapon spell_proc fixup: restore
                     SpellPhaseMask=1 (CAST) and AttributesMask=8
                     (REQ_SPELLMOD); previous _02 set 8/0 which
                     silently dropped every proc event.

Diagnostics from this debugging session demoted from LOG_INFO to
LOG_DEBUG (silent at default info level) so production logs stay
quiet but the probes remain available for reproducing future
regressions: pet_mage.cpp MirrorImage probe/kept/rebuild/init/
engage/cast lines and SpellInfo.cpp CheckShapeshift bypass line.

CLIENT-PATCHES.md updated to document the new Warrior stance DBC
patcher (_patch_spell_dbc_stances.py), the spell-tooltip post-
processor and stance UseAction wrapper in patch-enUS-5.MPQ, and
the Mirror Image / Maelstrom Weapon Paragon notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 14:54:05 -04:00
17 changed files with 924 additions and 40 deletions
@@ -21,7 +21,7 @@ when a release is published here (workflow **Sync release to Fractured-Distro**)
| Artifact | Size | Purpose |
|---|---|---|
| `patch-enUS-4.MPQ` | ~5 MB | DBC + GlueXML bake. Adds `CLASS_PARAGON` (id 12), the character-create slot, glue strings, game-table DBCs, and a patched `Spell.dbc`: **(1)** `RuneCostID` zeroed on every rune-cost spell so nonDeath Knight clients still send DK casts (rune costs are shown via `RuneFrame.lua`); **(2)** `Reagent[]` / `ReagentCount[]` zeroed on every spell whose `SpellFamilyName` is non-zero (all class abilities), while profession crafts (`SpellFamilyName == 0`) keep their materials. Both edits mirror server load-time corrections so client preflight and server validation stay aligned. Required for character creation as Paragon to even show up. |
| `patch-enUS-5.MPQ` | ~57 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
| `patch-enUS-5.MPQ` | ~64 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), a **spell-tooltip post-processor** that (1) recolors and appends "(Paragon: bypassed)" to "Requires *Stance*" lines on Warrior abilities (server-side `SpellInfo::CheckShapeshift` skips stance enforcement for Paragons on `SPELLFAMILY_WARRIOR` spells, but the client still renders the requirement from the stock `Stances` DBC field which we deliberately leave unzeroed so stock Warriors keep enforcement), (2) appends a Paragon line to **Maelstrom Weapon** (53817) noting that Fireball / Frostbolt / Arcane Blast also benefit, and (3) appends a Paragon line to **Mirror Image** (55342) noting that the images cast random damage spells with a cast time from the caster's spellbook instead of Frostbolt — all three patches gate on `UnitClass("player") == "PARAGON"` so stock-class tooltips are byte-identical to vanilla, and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). A **Warrior stance click bypass** wraps `UseAction` so that for Paragon characters, clicking an action slot bound to a stance-gated Warrior ability (Whirlwind, Charge, Pummel, Shield Slam, Hamstring, Overpower, Shield Bash, Shield Block, Disarm, Revenge, Spell Reflection, Recklessness, Bladestorm, Shockwave, Concussion Blow, Last Stand, Sweeping Strikes, Mocking Blow, Heroic Fury, Slam, Devastate, Intercept) routes through `CastSpellByName(name)` instead of the engine's stance-gated `UseAction` path; the engine's stance pre-check inside `UseAction` would otherwise drop the cast packet client-side and our server-side `CheckShapeshift` bypass would never get to run. Stock classes never enter the bypass branch. The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
| `patch-enUS-6.MPQ` | ~134 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast, and a **Builds** page (full-pane overlay opened from the bottom-row Builds button) for saving named, icon-tagged loadouts: New Build (+) icon picker reuses `MACRO_ICON_FILENAMES`, right-click for edit/delete, shift-left-click to favorite (favorites bubble to the top), left-click pops a Load Build confirm. Build swaps reset + refund AE/TE, re-spend on the saved recipe, and **park hunter pets** to `PET_SAVE_NOT_IN_SLOT` so their name/talents/exp are preserved across swaps. |
| `Wow.exe` | ~7.5 MB | 3.3.5a (build 12340) client byte-patched to skip the MPQ signature check so custom `patch-enUS-N.MPQ` files load. Diff against stock is a few bytes; everything else is unchanged. |
@@ -251,7 +251,8 @@ order on a maintainer machine:
1. `fractured-tooling/from-workspace-root/_patch_spell_dbc_runes.py` — stage `Spell.dbc` with `RuneCostID` cleared.
2. `fractured-tooling/from-workspace-root/_patch_spell_dbc_reagents.py` — same staged `Spell.dbc`, clear class-spell reagents for client preflight.
3. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
3. `fractured-tooling/from-workspace-root/_patch_spell_dbc_stances.py` — same staged `Spell.dbc`, zero the `Stances` field on every `SpellFamilyName == 4` (Warrior) row so the client engine's "Must be in Battle/Defensive/Berserker Stance" pre-cast check stops eating `CMSG_CAST_SPELL` packets for Paragon casters who never picked the stance form. The server's `SpellInfo::CheckShapeshift` Paragon bypass takes over from there. Stock Warriors still see the server-side stance error mid-cast if they actually click while out of stance.
4. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
The patched `Wow.exe` is a one-time hex-edit of the stock 3.3.5a
client. The diff is publicly documented in the WoW emulation community
@@ -0,0 +1,46 @@
-- mod-paragon: Vampiric Embrace (15286) cross-family wildcard.
--
-- Stock 3.3.5 spell_proc row for Vampiric Embrace gates by SpellFamilyName=6
-- (PRIEST) plus a Priest-Shadow-damage SpellFamilyMask. That blocks the proc
-- engine from ever calling the AuraScript's CheckProc when a Paragon casts a
-- non-Priest Shadow-school spell (e.g. Warlock Shadow Bolt, Death Knight
-- Death Coil, etc.), because IsAffected's familyFlags gate fails before
-- CheckProc runs even though the Phase A wildcard already loosens the
-- familyName equality test (see SpellInfo::IsAffected, listenerOwner overload).
--
-- We relax this row so:
-- * SchoolMask=32 (SHADOW) kept -- proc-engine still gates by school
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage events trigger CheckProc
-- * SpellPhaseMask=2 (HIT) kept -- post-hit phase
-- * AttributesMask=2 (TRIGGERED) kept -- triggered-spell payloads still proc
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into spell_pri_vampiric_embrace::CheckProc, which
-- branches on IsParagonWildcardCaller(GetTarget()):
--
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, accept any
-- single-target Shadow-school spell (Mind Sear / AoE shadow spells like
-- Seed of Corruption / Hellfire are filtered there via IsAffectingArea
-- and the existing Mind Sear bit-mask).
--
-- * For stock Priest owners (and for the non-wildcard runtime path), the
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=6 plus
-- the original 0x0280A010 / 0x00002402 / 0x00000008 SpellFamilyMask bits
-- -- so behavior is byte-identical to before this change for any caster
-- that is not a Paragon.
--
-- Net effect: Paragon characters with VE learned now leech-heal off any
-- single-target Shadow spell they cast (Death Coil, Shadow Bolt, Searing
-- Pain, Drain Soul, etc.); stock Shadow Priests are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` = 15286;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(15286, 32, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
@@ -0,0 +1,51 @@
-- mod-paragon: Maelstrom Weapon (53817) cross-family wildcard.
--
-- Stock 3.3.5 spell_proc row for Maelstrom Weapon gates by SpellFamilyName=11
-- (SHAMAN) plus a Shaman SpellFamilyMask covering Lightning Bolt, Chain
-- Lightning, Lesser Healing Wave, Healing Wave and Hex (Mask0=451,
-- Mask1=32768). The proc engine therefore never delivers an event to the
-- AuraScript when a Paragon casts a non-Shaman cast-time spell, even if the
-- IsAffected wildcard relaxes SpellFamilyName equality (the SpellFamilyMask
-- AND-with-target-FamilyFlags check still fails because Mage / Warlock /
-- Druid spell-class bits do not overlap with Shaman bits).
--
-- We relax this row so:
-- * SchoolMask=0 wildcard -- proc engine no longer gates by school
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage spells trigger CheckProc
-- * SpellPhaseMask=8 (FINISH) kept -- post-cast phase, on cast finish
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into spell_sha_maelstrom_weapon::CheckProc:
--
-- * For stock Shaman owners (and for the non-wildcard runtime path), the
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=11
-- plus the original Mask0=451 / Mask1=32768 bits -- so behavior is
-- byte-identical to before this change for any caster that is not a
-- Paragon.
--
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, the
-- stock allowlist still passes, AND we additionally accept the curated
-- Mage cast-time nukes Fireball / Frostbolt / Arcane Blast (any rank,
-- matched via GetFirstRankSpell).
--
-- The matching IsAffectedBySpellMod hook in SpellInfo.cpp ensures the cast
-- time + power cost spellmods on aura 53817 also bridge across families for
-- the same Mage spell allowlist, so Paragons get the full Maelstrom Weapon
-- experience (instant cast at 5 stacks + reduced mana cost) on Fireball,
-- Frostbolt and Arcane Blast.
--
-- Net effect: Paragon characters with Maelstrom Weapon learned now spend
-- stacks on Mage cast-time nukes in addition to the stock Shaman list;
-- stock Enhancement Shamans are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(53817, 0, 0, 0, 0, 0, 0, 1, 8, 0, 0, 0, 0, 0, 0, 0);
@@ -0,0 +1,66 @@
-- mod-paragon: Frostbite + Fingers of Frost cross-family wildcard.
--
-- Both talents are normally gated by SpellFamilyName=3 (MAGE) plus a Mage
-- SpellFamilyMask covering specific Mage Frost spells (Frostbolt / Frost Nova
-- / Cone of Cold / Blizzard / Frostfire Bolt / Deep Freeze for FoF; Frost
-- slow-applying spells for Frostbite). That blocks the proc engine from
-- delivering an event to the AuraScript when a Paragon casts a non-Mage
-- Frost-school chill effect (DK Howling Blast / Icy Touch / Chains of Ice,
-- Hunter Frost Trap, Shaman Frost Shock, etc.), because IsAffected's
-- familyFlags AND-with-target-FamilyFlags check fails before CheckProc runs
-- even after the Paragon family-name wildcard.
--
-- We relax these rows so:
-- * SchoolMask=16 (FROST) gate by Frost school at the proc engine
-- * SpellTypeMask=1 (DAMAGE) only damage events trigger CheckProc
-- * SpellPhaseMask=2 (HIT) post-hit phase
-- * AttributesMask=2 (TRIGGERED) triggered chill payloads still proc
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into the Mage AuraScripts:
--
-- spell_mage_fingers_of_frost_talent attached to 44543 / 44545
-- stock Mage : SpellFamilyName=MAGE AND original Mask0 0x100120 / Mask1 0x1000
-- Paragon : accept (FROST + DAMAGE gate already enforced)
--
-- spell_mage_frostbite attached to 11071 / 12496 / 12497
-- stock Mage : SpellFamilyName=MAGE AND original Mage Frost-slow Mask
-- (Frostbolt / Frost Nova / Cone of Cold / Blizzard / FFB)
-- Paragon : accept iff proc spell applies SPELL_AURA_MOD_DECREASE_SPEED
-- OR the Paragon already has a slow on the proc target
-- (covers the Improved-Blizzard-style "chill via separate
-- triggered aura" cross-class case)
--
-- Net effect: Paragon characters with these talents now have FoF / Frostbite
-- proc off cross-class Frost-school chill effects; stock Mages are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` IN (44543, 44545, 11071, 12496, 12497);
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
-- Fingers of Frost talent ranks (Chance 7% / 15% preserved from stock row).
(44543, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 7, 0, 0),
(44545, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 15, 0, 0),
-- Frostbite talent ranks (5% / 10% / 15% per rank, leave Chance=0 to use
-- the DBC ProcChance which already encodes the per-rank percentage).
(11071, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
(12496, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
(12497, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
-- Bind the new AuraScripts (defined in src/server/scripts/Spells/spell_mage.cpp).
DELETE FROM `spell_script_names`
WHERE `spell_id` IN (44543, 44545, 11071, 12496, 12497)
AND `ScriptName` IN ('spell_mage_fingers_of_frost_talent', 'spell_mage_frostbite');
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(44543, 'spell_mage_fingers_of_frost_talent'),
(44545, 'spell_mage_fingers_of_frost_talent'),
(11071, 'spell_mage_frostbite'),
(12496, 'spell_mage_frostbite'),
(12497, 'spell_mage_frostbite');
@@ -0,0 +1,51 @@
-- mod-paragon: Maelstrom Weapon (53817) spell_proc fixup.
--
-- The previous migration (2026_05_11_02.sql) had two bugs in the rewritten
-- spell_proc row that prevented stack consumption from firing at all -- not
-- just for our new Mage targets, but also for the stock Shaman cast-time
-- spells (Lightning Bolt, Chain Lightning, Lesser Healing Wave, etc.).
--
-- Bugs:
--
-- * SpellPhaseMask was set to 8. The valid values for SpellPhaseMask are
-- PROC_SPELL_PHASE_CAST = 1
-- PROC_SPELL_PHASE_HIT = 2
-- PROC_SPELL_PHASE_FINISH = 4
-- (see SpellMgr.h). Anything else, including 8, never matches a real
-- proc event, so the proc engine silently dropped every event before it
-- reached the AuraScript. The original stock row uses 1 (CAST), which
-- is what fires when the cast packet's setup phase completes -- exactly
-- when we want the spellmod-affected cast to consume the buff.
--
-- * AttributesMask was set to 0. The original stock row uses 8
-- PROC_ATTR_REQ_SPELLMOD = 0x8
-- which says "only proc on spells that were affected by one of this
-- aura's spellmods". This is the bridge between IsAffectedBySpellMod
-- (which records the aura into Spell::m_appliedMods when calculating
-- cast time / cost) and the proc system (which then knows that the cast
-- used the buff and should consume a charge). Without this attribute,
-- the proc would either fire too aggressively or not at all depending
-- on subsequent gating, but in practice the engine relies on it to
-- correlate spellmod use with stack consumption.
--
-- Fixed row keeps the same family/mask wildcards from the previous
-- migration (so the Paragon Mage allowlist in spell_sha_maelstrom_weapon's
-- CheckProc still gets the chance to filter), restores SpellPhaseMask=1
-- (CAST) and AttributesMask=8 (REQ_SPELLMOD) to match stock semantics, and
-- resets SpellTypeMask to 0 (any spell type -- the REQ_SPELLMOD attribute
-- already gates by "was the buff actually used", so an extra DAMAGE filter
-- is redundant and would block e.g. Lesser Healing Wave on stock Shamans).
--
-- This restores stock Shaman behavior byte-identically and lets Paragon
-- Mage casts also consume stacks via the IsAffectedBySpellMod allowlist.
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(53817, 0, 0, 0, 0, 0, 0, 0, 1, 0, 8, 0, 0, 0, 0, 0);
+33 -2
View File
@@ -7143,6 +7143,16 @@ bool Player::CheckAttackFitToAuraRequirement(WeaponAttackType attackType, AuraEf
if (spellInfo->EquippedItemClass == -1)
return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass
// gates on per-swing proc matches. A Paragon's talent list spans every
// class so a stock weapon-subclass mask (e.g. Maelstrom Weapon's
// axe/mace/staff/fist/dagger restriction) excludes weapons the player
// can legitimately wield. Accept any equipped weapon in attackType slot
// when listener is a Paragon AND the spell gates on ITEM_CLASS_WEAPON;
// ITEM_CLASS_ARMOR (shield) gates still enforce the original mask.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this))
return GetWeaponForAttack(attackType, true) != nullptr;
Item* item = GetWeaponForAttack(attackType, true);
if (!item || !item->IsFitToSpellRequirements(spellInfo))
return false;
@@ -7208,7 +7218,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
return;
// Cannot be used in this stance/form
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) != SPELL_CAST_OK)
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) != SPELL_CAST_OK)
return;
if (form_change) // check aura active state from other form
@@ -7228,7 +7238,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
if (form_change) // check aura compatibility
{
// Cannot be used in this stance/form
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) == SPELL_CAST_OK)
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) == SPELL_CAST_OK)
return; // and remove only not compatible at form change
}
@@ -12581,6 +12591,27 @@ bool Player::HasItemFitToSpellRequirements(SpellInfo const* spellInfo, Item cons
if (spellInfo->EquippedItemClass < 0)
return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass
// gates so passive talent auras (e.g. Maelstrom Weapon talents 51528-51532)
// attach for any equipped weapon, not just the talent's restrictive
// subclass mask. Mirrors CheckAttackFitToAuraRequirement so per-swing
// proc match agrees with talent-attach time. Still requires *some* weapon
// to be equipped (otherwise unarmed Paragons would auto-activate every
// weapon-gated talent in the game). ITEM_CLASS_ARMOR (shield) is left
// alone -- shield-gated talents still need an actual shield.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this))
{
for (uint8 i = EQUIPMENT_SLOT_MAINHAND; i < EQUIPMENT_SLOT_TABARD; ++i)
if (Item const* item = GetUseableItemByPos(INVENTORY_SLOT_BAG_0, i))
if (item != ignoreItem)
if (ItemTemplate const* proto = item->GetTemplate())
if (proto->Class == ITEM_CLASS_WEAPON)
return true;
// No weapon equipped at all -- fall through to stock logic, which
// returns false for passive talent auras (correct: an unarmed
// Paragon shouldn't have weapon talents active).
}
// scan other equipped items for same requirements (mostly 2 daggers/etc)
// for optimize check 2 used cases only
switch (spellInfo->EquippedItemClass)
+43 -8
View File
@@ -78,6 +78,19 @@
#include <cmath>
#include <limits>
// Fractured / Paragon: single source of truth for the runtime "is this
// caller eligible for the cross-class wildcard?" question. Centralizing
// here keeps every dependent behavior (family-name skip in
// SpellInfo::IsAffected, PERIODIC_LEECH disease counting in
// GetDiseasesByCaster, instant-cast intercept in Spell::prepare for
// Predator's / Nature's Swiftness, Vampiric Embrace CheckProc cross-family
// path, etc.) flipping in lockstep when the config flag is toggled.
bool IsParagonWildcardCaller(Unit const* listener)
{
return listener && listener->getClass() == CLASS_PARAGON
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
}
// Fractured / Paragon: cross-class wildcard helper used by ad-hoc
// `switch (SpellFamilyName)` listener gates in Unit / SpellEffects /
// SpellAuraEffects. Returns true when the listener is a CLASS_PARAGON
@@ -85,8 +98,7 @@
// to strict family-name equality.
bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily)
{
if (listener && listener->getClass() == CLASS_PARAGON
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY))
if (IsParagonWildcardCaller(listener))
return true;
return expectedFamily == actualFamily;
}
@@ -6136,17 +6148,40 @@ AuraEffect* Unit::IsScriptOverriden(SpellInfo const* spell, int32 script) const
uint32 Unit::GetDiseasesByCaster(ObjectGuid casterGUID, uint8 mode)
{
static const AuraType diseaseAuraTypes[] =
ObjectGuid drwGUID;
// Fractured / Paragon: when the caller (the unit whose strike is
// counting diseases -- e.g. Death Strike heal, Blood Strike / Heart
// Strike / Obliterate per-disease damage, Glyph of Scourge Strike
// refresh) is a CLASS_PARAGON player AND Paragon.WildcardFamilyMatching
// is on, also walk SPELL_AURA_PERIODIC_LEECH. That picks up Priest
// Devouring Plague, which uses ApplyAuraName 53 (PERIODIC_LEECH) instead
// of 3 (PERIODIC_DAMAGE) and is therefore invisible to the stock loop
// even though its Dispel field is DISPEL_DISEASE. A full Spell.dbc scan
// confirms Devouring Plague is the ONLY entry that satisfies both
// `Dispel == DISPEL_DISEASE` and a leech periodic effect, so this does
// not accidentally drag any other spell into the disease pool. Stock
// (non-Paragon) callers fall through to the original 2-entry iteration
// and observe identical behavior.
bool paragonWildcardLeech = false;
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
{
drwGUID = playerCaster->getRuneWeaponGUID();
paragonWildcardLeech = IsParagonWildcardCaller(playerCaster);
}
AuraType diseaseAuraTypes[4] =
{
SPELL_AURA_PERIODIC_DAMAGE, // Frost Fever and Blood Plague
SPELL_AURA_LINKED, // Crypt Fever and Ebon Plague
SPELL_AURA_NONE,
SPELL_AURA_NONE
};
ObjectGuid drwGUID;
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
drwGUID = playerCaster->getRuneWeaponGUID();
if (paragonWildcardLeech)
{
diseaseAuraTypes[2] = SPELL_AURA_PERIODIC_LEECH; // Priest Devouring Plague (Paragon-only)
diseaseAuraTypes[3] = SPELL_AURA_NONE;
}
uint32 diseases = 0;
for (uint8 index = 0; diseaseAuraTypes[index] != SPELL_AURA_NONE; ++index)
+10
View File
@@ -2268,6 +2268,16 @@ private:
ValuesUpdateCache _valuesUpdateCache;
};
// Fractured / Paragon: returns true iff `listener` is a CLASS_PARAGON player
// AND `Paragon.WildcardFamilyMatching` is enabled. Single source of truth for
// the gate that controls every cross-class wildcard path (family-name skip in
// SpellInfo::IsAffected, leech-aura disease counting in
// Unit::GetDiseasesByCaster, the cross-school instant-cast intercept in
// Spell::prepare for Predator's / Nature's Swiftness, the Vampiric Embrace
// CheckProc cross-family path, etc.). Centralizing the check means runtime
// kill-switching the wildcard config flips every behavior together.
[[nodiscard]] bool IsParagonWildcardCaller(Unit const* listener);
// Fractured / Paragon: helper for ad-hoc `switch (SpellFamilyName)` listener
// gates scattered across Unit / SpellEffects / SpellAuraEffects. When the
// listener (i.e. the unit holding the gating talent / aura) is a Paragon
@@ -1630,7 +1630,7 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
// Xinef: Remove autoattack spells
if (Spell* spell = target->GetCurrentSpell(CURRENT_MELEE_SPELL))
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0) != SPELL_CAST_OK)
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0, target) != SPELL_CAST_OK)
spell->cancel(true);
}
}
+19 -1
View File
@@ -2251,8 +2251,26 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
item = target->ToPlayer()->GetUseableItemByPos(INVENTORY_SLOT_BAG_0, EQUIPMENT_SLOT_OFFHAND);
}
if (!item || item->IsBroken() || !item->IsFitToSpellRequirements(GetSpellInfo()))
if (!item || item->IsBroken())
return 0;
if (!item->IsFitToSpellRequirements(GetSpellInfo()))
{
// Fractured / Paragon: cross-class wildcard relaxes weapon-
// class subclass gates on per-event proc evaluation. This
// mirrors Player::CheckAttackFitToAuraRequirement and
// Player::HasItemFitToSpellRequirements -- without this
// third bypass, the talent attaches (HasItemFit lets it),
// the per-swing match accepts the weapon (CheckAttackFit
// lets it), but IsProcTriggeredOnEvent still kills the
// proc here for any weapon outside the talent's stock
// subclass mask (e.g. Maelstrom Weapon on a Paragon
// wielding a 1H sword or polearm). Restricted to
// ITEM_CLASS_WEAPON so shield-gated talents still need
// an actual shield.
if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(target)))
return 0;
}
}
}
+37 -16
View File
@@ -51,6 +51,7 @@
#include "Vehicle.h"
#include "World.h"
#include "WorldPacket.h"
#include <array>
#include <cmath>
/// @todo: this import is not necessary for compilation and marked as unused by the IDE
@@ -3540,29 +3541,49 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
m_casttime = 0;
// Fractured / Paragon: cross-class Predator's Swiftness (69369).
// Stock 3.3.5 only ADD_PCT_MODIFIER's the cast time of Druid-family
// Nature spells via class mask, so a Paragon with the buff cannot
// instant-cast Shaman Chain Lightning / Lightning Bolt or any other
// non-Druid Nature spell. The tooltip ("next Nature spell with a
// base cast time below 10 sec becomes instant") expects all-Nature
// behavior; honor that here for CLASS_PARAGON. We deliberately do
// not touch the stock SpellMod path -- real Druids continue to hit
// the existing class-mask code path unchanged.
// Fractured / Paragon: cross-class "next Nature spell becomes instant"
// intercept for the three buffs that share that semantic in 3.3.5:
//
// 69369 - Predator's Swiftness (Cataclysm proc payload triggered by
// our spell_paragon_predatory_strikes; see Paragon_SC.cpp)
// 17116 - Druid Nature's Swiftness
// 16188 - Shaman Nature's Swiftness
//
// All three apply SPELL_AURA_ADD_PCT_MODIFIER on SPELLMOD_CASTING_TIME
// gated by a Druid- or Shaman-only SpellClassMask, so a Paragon with the
// buff cannot instant-cast a Nature spell from a different family
// (e.g. a Druid NS Paragon casting Shaman Chain Lightning, or a Shaman
// NS Paragon casting Druid Healing Touch). Tooltip text on all three
// promises "next Nature spell with a base cast time below 10 sec becomes
// instant"; honor that here for CLASS_PARAGON callers when the wildcard
// config is on. The stock SpellMod path is untouched -- real Druids /
// Shamans / proc consumers continue to hit the existing class-mask code
// path unchanged.
if (Player* paragonCaster = m_caster->ToPlayer())
{
constexpr uint32 SPELL_PARAGON_PREDATORY_SWIFTNESS = 69369;
if (m_casttime > 0
&& paragonCaster->getClass() == CLASS_PARAGON
&& IsParagonWildcardCaller(paragonCaster)
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
&& m_spellInfo->CastTimeEntry
&& !m_spellInfo->IsChanneled()
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS
&& paragonCaster->HasAura(SPELL_PARAGON_PREDATORY_SWIFTNESS))
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS)
{
m_casttime = 0;
paragonCaster->RemoveAurasDueToSpell(SPELL_PARAGON_PREDATORY_SWIFTNESS);
static constexpr std::array<uint32, 3> kParagonNatureInstantBuffs =
{
69369u, // Predator's Swiftness (Paragon proc payload)
17116u, // Druid Nature's Swiftness
16188u // Shaman Nature's Swiftness
};
for (uint32 buffId : kParagonNatureInstantBuffs)
{
if (paragonCaster->HasAura(buffId))
{
m_casttime = 0;
paragonCaster->RemoveAurasDueToSpell(buffId);
break; // consume only one buff per cast
}
}
}
}
@@ -5748,7 +5769,7 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
if (checkForm)
{
// Cannot be used in this stance/form
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm());
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm(), m_caster);
if (shapeError != SPELL_CAST_OK)
return shapeError;
+69 -2
View File
@@ -1378,7 +1378,42 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* liste
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
return true;
return IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner);
if (IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner))
return true;
// Fractured / Paragon: explicit cross-family allowlist for specific
// listener auras whose SpellClassMask cannot otherwise bridge classes.
// The standard IsAffected wildcard relaxes SpellFamilyName equality but
// still requires SpellClassMask & SpellFamilyFlags to overlap; for these
// Paragon-only cross-class enablers the source spells live in different
// families with non-overlapping class bits, so we whitelist by mod owner
// spell ID + target spell first-rank ID. Stock classes never enter here
// because IsParagonWildcardCaller short-circuits on non-Paragon owners.
if (IsParagonWildcardCaller(listenerOwner))
{
switch (mod->spellId)
{
case 53817: // Shaman: Maelstrom Weapon
{
// Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to
// benefit from the cast-time + cost reduction spellmod.
if (SpellFamilyName == SPELLFAMILY_MAGE)
{
SpellInfo const* first = GetFirstRankSpell();
uint32 firstId = first ? first->Id : Id;
if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/
|| firstId == 30451 /*Arcane Blast*/)
return true;
}
break;
}
default:
break;
}
}
return false;
}
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
@@ -1463,7 +1498,7 @@ bool SpellInfo::IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInf
}
}
SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
SpellCastResult SpellInfo::CheckShapeshift(uint32 form, Unit const* caster /*= nullptr*/) const
{
// talents that learn spells can have stance requirements that need ignore
// (this requirement only for client-side stance show in talent description)
@@ -1471,6 +1506,38 @@ SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
(Effects[0].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[1].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[2].Effect == SPELL_EFFECT_LEARN_SPELL))
return SPELL_CAST_OK;
// Fractured / Paragon: Paragons learn Warrior abilities through Advancement
// without picking up Battle/Defensive/Berserker Stance, so stance-gated
// Warrior spells (e.g. Whirlwind, Sunder Armor, Shield Slam) would otherwise
// be uncastable. Bypass the stance check for Paragon casters on any spell
// that has a non-zero Stances bitmask, regardless of SpellFamilyName.
//
// We previously gated this on SpellFamilyName == SPELLFAMILY_WARRIOR, but a
// number of SPELLFAMILY_GENERIC spells (notably the iconic Warrior toolbox
// -- Berserker Rage 18499, Sunder Armor 7405 / 11596 / 11597 / 25225 /
// 47467, Charge 100 / 6178 / 11578, Pummel 6552 / 6554, Shield Bash 72 /
// 1671 / 1672 / 29704, Retaliation 20230, Recklessness 1719, Shield Wall
// 871, etc.) carry the Stances bitmask but live under SPELLFAMILY_GENERIC
// (family 0). The previous narrower gate let those re-trigger the stance
// failure for Paragons. Widening to "any non-zero Stances + Paragon" is
// safe because:
//
// * The bypass returns SPELL_CAST_OK only when IsParagonWildcardCaller
// is true -- stock classes never enter this branch.
// * Druid form-gated spells (Cat Form / Bear Form / Moonkin / Tree)
// still fire the Druid GCD/form rules elsewhere; CheckShapeshift is
// about *requiring* a form to cast, which is exactly what we want
// to bypass for Paragons (they never picked the form).
// * Item enchant scrolls and other shapeshift-marked utility spells
// remain unaffected because they aren't in a Paragon's spellbook.
if (Stances != 0 && IsParagonWildcardCaller(caster))
{
LOG_DEBUG("server.scripts",
"[paragon-diag] CheckShapeshift bypass: spell={} family={} stances=0x{:x} form={}",
Id, SpellFamilyName, Stances, form);
return SPELL_CAST_OK;
}
uint32 stanceMask = (form ? 1 << (form - 1) : 0);
if (stanceMask & StancesNot) // can explicitly not be casted in this stance
+1 -1
View File
@@ -521,7 +521,7 @@ public:
bool IsAuraExclusiveBySpecificWith(SpellInfo const* spellInfo) const;
bool IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInfo) const;
SpellCastResult CheckShapeshift(uint32 form) const;
SpellCastResult CheckShapeshift(uint32 form, Unit const* caster = nullptr) const;
SpellCastResult CheckLocation(uint32 map_id, uint32 zone_id, uint32 area_id, Player* player = nullptr, bool strict = true) const;
SpellCastResult CheckTarget(Unit const* caster, WorldObject const* target, bool implicit = true) const;
SpellCastResult CheckExplicitTarget(Unit const* caster, WorldObject const* target, Item const* itemTarget = nullptr) const;
+312 -3
View File
@@ -64,10 +64,227 @@ struct npc_pet_mage_mirror_image : CasterAI
uint32 dist = urand(1, 5);
bool _delayAttack;
// Fractured / Paragon: when the owner is a Paragon character with the
// wildcard config enabled, replace the stock Frostbolt + Fireblast
// allowlist (loaded by CombatAI from creature_template_spell for
// creature 31216) with a curated list of damaging spells from the
// owner's spellbook. UpdateAI's override picks a random spell from
// the list per cast so the rotation isn't deterministic.
//
// The image still casts as itself (not via the owner), so spell
// coefficients apply to the image's stats -- spells naturally do less
// damage than they would in the owner's hands. We accept that as the
// cost of "free cross-class spell variety" rather than try to rebalance
// every player spell here.
static bool IsDamagingForMirrorImage(SpellInfo const* si)
{
// Direct damage effect.
if (si->HasEffect(SPELL_EFFECT_SCHOOL_DAMAGE))
return true;
// Spells like Arcane Missiles (TRIGGER_MISSILE) and most channeled
// multi-tick nukes route their damage through a child spell, so the
// parent has no SCHOOL_DAMAGE effect of its own. Accept that here.
if (si->HasEffect(SPELL_EFFECT_TRIGGER_MISSILE))
return true;
// DoTs and channels-as-aura (Mind Flay, Curse of Doom, Immolate,
// Corruption, Vampiric Touch, Drain Life leech, etc.). Also accept
// PERIODIC_TRIGGER_SPELL auras -- that's how Arcane Missiles fires
// each individual missile (parent has Aura=23 -> child damaging
// spell). Same pattern is used by Hunter Volley, Curse of Doom (in
// some ranks), and similar tick-by-trigger spells.
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
uint32 aura = si->Effects[i].ApplyAuraName;
if (aura == SPELL_AURA_PERIODIC_DAMAGE
|| aura == SPELL_AURA_PERIODIC_DAMAGE_PERCENT
|| aura == SPELL_AURA_PERIODIC_LEECH
|| aura == SPELL_AURA_PERIODIC_TRIGGER_SPELL)
return true;
}
return false;
}
void RebuildSpellsFromOwnerSpellbookForParagon(Player* owner)
{
SpellVct curated;
curated.reserve(8);
uint32 scanned = 0, kept = 0, rejInactive = 0, rejPassive = 0, rejWeaponStrike = 0,
rejNoDmg = 0, rejAoe = 0, rejGate = 0, rejLongCD = 0, rejLowRank = 0;
// For diagnosis: collect IDs of spells we'd expect to keep (Fireball,
// Frostbolt, Lightning Bolt, Mind Blast, Shadow Bolt, etc.) but that
// we instead reject. The sample is small so per-spell logging is OK.
auto trackProbe = [&](uint32 spellId, char const* phase)
{
// Only log "interesting" spell IDs to avoid 177-line spam per image.
// These are first-rank IDs of common cross-class single-target nukes.
static constexpr uint32 probes[] = {
133, 116, 30451, // Mage: Fireball, Frostbolt, Arcane Blast
5143, // Mage: Arcane Missiles (channel via PERIODIC_TRIGGER_SPELL)
403, 529, 8042, // Shaman: Lightning Bolt, Chain Lightning, Earth Shock
585, 14914, // Priest: Smite, Holy Fire
8092, 15407, // Priest: Mind Blast, Mind Flay
686, 348, // Warlock: Shadow Bolt, Immolate (DoT w/ cast time)
5176, 2912, // Druid: Wrath, Starfire
635, // Paladin: Holy Light
};
for (uint32 probe : probes)
{
if (spellId == probe)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage probe spell={} phase={}",
spellId, phase);
return;
}
// Also walk rank chain: if the spellbook has rank N of probe,
// probe matches via GetFirstRankSpell.
if (SpellInfo const* si = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = si->GetFirstRankSpell())
if (first->Id == probe)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage probe spell={} (rank of {}) phase={}",
spellId, probe, phase);
return;
}
}
};
for (auto const& kv : owner->GetSpellMap())
{
++scanned;
uint32 spellId = kv.first;
PlayerSpell const* ps = kv.second;
if (!ps || ps->State == PLAYERSPELL_REMOVED || !ps->Active)
{
++rejInactive;
trackProbe(spellId, "inactive");
continue;
}
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
if (!spellInfo)
continue;
// Spec (per user): damaging single-target spells, instant or
// cast-time or channeled all OK, no melee/ranged "strike" style
// weapon-attack abilities, and no long-cooldown spells (>10s) so
// the image cycles through a varied rotation rather than blowing
// a 2-min cooldown once.
if (spellInfo->IsPassive()) { ++rejPassive; trackProbe(spellId, "passive"); continue; }
if (!IsDamagingForMirrorImage(spellInfo)) { ++rejNoDmg; trackProbe(spellId, "noDmg"); continue; }
if (spellInfo->IsAffectingArea()) { ++rejAoe; trackProbe(spellId, "aoe"); continue; }
if (spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE
|| spellInfo->DmgClass == SPELL_DAMAGE_CLASS_RANGED) { ++rejWeaponStrike; trackProbe(spellId, "weaponStrike"); continue; }
// Reject anything with a base cooldown longer than 10s (either
// RecoveryTime or CategoryRecoveryTime). A 0/very-short CD is
// fine. The mage Mirror Image only lives for 30s, so anything
// gated by a long CD would only ever fire once anyway.
uint32 cd = std::max(spellInfo->RecoveryTime, spellInfo->CategoryRecoveryTime);
if (cd > 10000) { ++rejLongCD; trackProbe(spellId, "longCD"); continue; }
// Skip spells the image would never realistically be able to
// cast successfully or whose side-effects don't make sense on a
// pet (totems, summons, item / reagent / focus requirements,
// ranged-weapon / shapeshift / stealth gates, profession spells,
// teleports, etc.).
char const* gateReason = nullptr;
if (spellInfo->RequiresSpellFocus) gateReason = "focus";
else if (spellInfo->Reagent[0] > 0) gateReason = "reagent";
else if (spellInfo->Stances || spellInfo->StancesNot) gateReason = "stance";
else if (spellInfo->EquippedItemClass >= 0) gateReason = "equipped";
else if (spellInfo->IsCooldownStartedOnEvent()) gateReason = "cdEvent";
else if (spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE)) gateReason = "attrPassive";
else if (spellInfo->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) gateReason = "attrHidden";
// SPELL_ATTR0_NOT_SHAPESHIFTED is intentionally NOT a gate -- it
// means "cannot be cast while caster IS shapeshifted", not "this
// spell requires a shapeshift". The attribute is set on every
// standard caster nuke (Fireball, Frostbolt, Lightning Bolt,
// Shadow Bolt, etc.) and Mirror Images are never shapeshifted,
// so the runtime check trivially passes for them. Filtering on
// it here was the bug that left the curated list empty.
else if (spellInfo->HasAttribute(SPELL_ATTR0_ONLY_STEALTHED)) gateReason = "attrStealth";
// SPELL_ATTR1_NO_AUTOCAST_AI is intentionally NOT a gate -- it is set
// on most player nukes (Fireball / Lightning Bolt / Shadow Bolt) to
// stop class pets from auto-casting them. Mirror Images are
// server-curated player-spell mimics, so we WANT to auto-cast
// those exact spells.
else if (spellInfo->HasAttribute(SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE)) gateReason = "attrFailImmune";
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON)) gateReason = "fxSummon";
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON_PET)) gateReason = "fxSummonPet";
else if (spellInfo->HasEffect(SPELL_EFFECT_TELEPORT_UNITS)) gateReason = "fxTeleport";
else if (spellInfo->HasEffect(SPELL_EFFECT_TRANS_DOOR)) gateReason = "fxTransDoor";
else if (spellInfo->HasEffect(SPELL_EFFECT_OPEN_LOCK)) gateReason = "fxOpenLock";
else if (spellInfo->HasEffect(SPELL_EFFECT_INSTAKILL)) gateReason = "fxInstakill";
else if (spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL)) gateReason = "fxLearn";
if (gateReason) { ++rejGate; trackProbe(spellId, gateReason); continue; }
// Ignore spell ranks below the highest the player owns -- the
// spellbook contains all learned ranks; we want only the latest.
if (SpellInfo const* nextRank = spellInfo->GetNextRankSpell())
if (owner->HasSpell(nextRank->Id))
{ ++rejLowRank; trackProbe(spellId, "lowRank"); continue; }
++kept;
curated.push_back(spellId);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage kept spell={} ({})",
spellId,
spellInfo->SpellName[0] ? spellInfo->SpellName[0] : "?");
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild owner={} scanned={} kept={} "
"rejInactive={} rejPassive={} rejNoDmg={} rejAoe={} rejWeaponStrike={} rejLongCD={} rejGate={} rejLowRank={}",
owner->GetName(), scanned, kept,
rejInactive, rejPassive, rejNoDmg, rejAoe, rejWeaponStrike, rejLongCD, rejGate, rejLowRank);
if (curated.empty())
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild for {} produced empty list, keeping stock 59637/59638",
owner->GetName());
return; // keep stock 59637 / 59638 fallback
}
// Log the first few spell IDs we picked so we can verify the list.
std::string sample;
for (size_t i = 0; i < curated.size() && i < 8; ++i)
{
if (!sample.empty())
sample += ',';
sample += std::to_string(curated[i]);
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild swapping spells for {} (sample: {})",
owner->GetName(), sample);
spells.swap(curated);
}
void InitializeAI() override
{
CasterAI::InitializeAI();
// Fractured / Paragon: do the spellbook rebuild EARLY -- before
// owner->CastSpell(CLONE_ME) and before any threat-list inheritance,
// because any of those can synchronously fire JustEngagedWith on the
// image and cause CasterAI::JustEngagedWith to schedule events from
// the stock [59638 Frostbolt, 59637 Fireblast] m_spells[] entries
// before our swap takes effect. The override of JustEngagedWith
// below also reasserts the swap + flushes events, so even if a later
// combat-entry path fires JustEngagedWith again it picks up the
// curated list.
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner))
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
_delayAttack = true;
me->m_Events.AddEventAtOffset([this]()
{
@@ -76,11 +293,21 @@ struct npc_pet_mage_mirror_image : CasterAI
Unit* owner = me->GetOwner();
if (!owner)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage InitializeAI: no owner, spells.size={} (stock)",
spells.size());
return;
}
// Clone Me!
owner->CastSpell(me, SPELL_MAGE_CLONE_ME, true);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage InitializeAI: post-rebuild spells.size={} first={}",
spells.size(),
spells.empty() ? 0u : spells.front());
// xinef: Glyph of Mirror Image (4th copy)
float angle = 0.0f;
switch (me->GetUInt32Value(UNIT_CREATED_BY_SPELL))
@@ -139,6 +366,37 @@ struct npc_pet_mage_mirror_image : CasterAI
me->m_Events.AddEventAtOffset(new DeathEvent(*me), 29500ms);
}
void JustEngagedWith(Unit* who) override
{
// Fractured / Paragon: re-apply the spellbook rebuild here as well,
// because the engagement can fire synchronously from inside
// InitializeAI (via owner->CastSpell(CLONE_ME) or summon-side threat
// propagation) BEFORE InitializeAI's own rebuild call has run.
// Re-running it here is cheap and idempotent: the curated list is
// re-derived from the owner's current spellbook, and we wipe any
// previously-scheduled events so the stock 59637 / 59638 entries
// CasterAI may already have queued get evicted before scheduling.
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner))
{
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
events.Reset();
}
std::string sample;
for (size_t i = 0; i < spells.size() && i < 8; ++i)
{
if (!sample.empty()) sample += ',';
sample += std::to_string(spells[i]);
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage JustEngagedWith: spells.size={} sample=[{}] who={}",
spells.size(), sample, who ? who->GetName() : "<null>");
CasterAI::JustEngagedWith(who);
}
// Do not reload Creature templates on evade mode enter - prevent visual lost
void EnterEvadeMode(EvadeReason /*why*/) override
{
@@ -217,10 +475,61 @@ struct npc_pet_mage_mirror_image : CasterAI
if (me->HasUnitState(UNIT_STATE_CASTING))
return;
if (uint32 spellId = events.ExecuteEvent())
if (uint32 queuedId = events.ExecuteEvent())
{
events.RescheduleEvent(spellId, spellId == 59637 ? 6500ms : 2500ms);
me->CastSpell(me->GetVictim(), spellId, false);
// Fractured / Paragon: when the curated spellbook list is in
// play, pick a random spell from it for THIS cast instead of
// using the EventMap-scheduled spellId directly. The events
// queue (populated by CasterAI::JustEngagedWith) is otherwise
// deterministic for our small list and the image ends up
// rotating in lockstep; randomizing here makes each image
// (and each cast) feel like a mage ad-libbing from the
// player's repertoire.
uint32 actualId = queuedId;
bool isParagon = false;
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner) && !spells.empty())
{
actualId = spells[urand(0, uint32(spells.size()) - 1)];
isParagon = true;
}
// Reschedule the queue based on the spell we actually cast,
// not the one originally queued. For channeled spells this
// matters: Arcane Missiles is a 5s channel, so if we keep
// rescheduling every 2.5s the image is always either mid-
// channel or immediately re-rolling for another channel,
// and over four images you see effectively continuous
// Arcane Missiles. Wait for cast/channel to finish + a
// small breather before picking again.
Milliseconds nextDelay = (queuedId == 59637 ? 6500ms : 2500ms);
if (isParagon)
{
if (SpellInfo const* picked = sSpellMgr->GetSpellInfo(actualId))
{
uint32 castMs = picked->CalcCastTime();
uint32 chanMs = 0;
if (picked->IsChanneled())
{
int32 dur = picked->GetDuration();
if (dur > 0)
chanMs = uint32(dur);
}
uint32 minMs = std::max(castMs, chanMs) + 750; // breather
if (Milliseconds(minMs) > nextDelay)
nextDelay = Milliseconds(minMs);
}
}
events.RescheduleEvent(queuedId, nextDelay);
SpellCastResult castRes = me->CastSpell(me->GetVictim(), actualId, false);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage cast spell={} victim={} result={} nextDelay={}ms",
actualId,
me->GetVictim() ? me->GetVictim()->GetName() : "<null>",
uint32(castRes),
uint32(nextDelay.count()));
}
}
};
+115 -2
View File
@@ -490,12 +490,22 @@ class spell_mage_cold_snap : public SpellScript
{
Player* caster = GetCaster()->ToPlayer();
// immediately finishes the cooldown on Frost spells
//
// Fractured / Paragon: ParagonFamilyMatches() drops the
// SpellFamilyName == SPELLFAMILY_MAGE gate when the caster is a
// CLASS_PARAGON player AND Paragon.WildcardFamilyMatching is on,
// so any Frost-school spell in the Paragon's spellbook with a real
// recovery time (Howling Blast, Frost Shock, Frost Trap, etc.)
// also gets its cooldown wiped. Stock Mage callers fall through to
// strict family-name equality and observe identical behavior.
PlayerSpellMap const& spellMap = caster->GetSpellMap();
for (PlayerSpellMap::const_iterator itr = spellMap.begin(); itr != spellMap.end(); ++itr)
{
SpellInfo const* spellInfo = sSpellMgr->AssertSpellInfo(itr->first);
if (spellInfo->SpellFamilyName == SPELLFAMILY_MAGE && (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST) && spellInfo->Id != SPELL_MAGE_COLD_SNAP && spellInfo->GetRecoveryTime() > 0)
if (ParagonFamilyMatches(caster, SPELLFAMILY_MAGE, spellInfo->SpellFamilyName)
&& (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST)
&& spellInfo->Id != SPELL_MAGE_COLD_SNAP
&& spellInfo->GetRecoveryTime() > 0)
{
SpellCooldowns::iterator citr = caster->GetSpellCooldownMap().find(spellInfo->Id);
if (citr != caster->GetSpellCooldownMap().end() && citr->second.needSendToClient)
@@ -946,6 +956,107 @@ class spell_mage_summon_water_elemental : public SpellScript
}
};
// 44543, 44545 - Fingers of Frost (talent ranks - the proc-trigger aura, NOT the
// 74396 buff aura that is APPLIED when this talent fires).
//
// Stock spell_proc gates this talent by SpellFamilyName=MAGE plus a
// SpellFamilyMask covering the Mage Frost spells that count as "chill-effect
// dealers" (Frostbolt / Frost Nova / Cone of Cold / Blizzard / Frostfire Bolt /
// Deep Freeze etc.). For Paragon characters with `Paragon.WildcardFamilyMatching`
// enabled, we relax the spell_proc row to wildcard family/mask + SchoolMask=
// FROST + SpellTypeMask=DAMAGE so that any Frost-school damage spell (DK Howling
// Blast / Icy Touch, Hunter Frost Trap / Wing Clip-as-frost, Shaman Frost Shock,
// Druid Hibernate damage payload, etc.) reaches this CheckProc; this script
// then re-enforces the stock Mage allowlist for non-Paragon owners and lets
// Paragons through unconditionally (the FROST + DAMAGE gate already happens at
// the spell_proc layer, so any spell reaching us here is safe to accept).
class spell_mage_fingers_of_frost_talent : public AuraScript
{
PrepareAuraScript(spell_mage_fingers_of_frost_talent);
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock Mage allowlist: re-derive from this talent's own effect-0
// SpellClassMask so behavior matches the original auto-generated
// proc filter exactly (no risk of mask drift across DBC versions).
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
return true;
return IsParagonWildcardCaller(GetUnitOwner());
}
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_mage_fingers_of_frost_talent::CheckProc);
}
};
// 11071, 12496, 12497 - Frostbite (talent ranks - the proc-trigger aura that
// chains into 12494 Frostbite freeze).
//
// Stock spell_proc (auto-generated from DBC) gates this talent by Mage family +
// the talent's effect SpellClassMask (Mage Frost slow-applying spells). For
// Paragon characters we relax the row to SchoolMask=FROST wildcard so that
// chill-applying Frost spells from any class can reach this CheckProc; the
// Paragon path additionally requires the proc spell to actually apply a slow
// (SPELL_AURA_MOD_DECREASE_SPEED) so that pure damage Frost spells without a
// chill component (e.g. raw Ice Lance on a non-frozen target) do NOT freeze.
// Stock Mage owners get the original behavior re-enforced here.
class spell_mage_frostbite : public AuraScript
{
PrepareAuraScript(spell_mage_frostbite);
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock Mage path: re-derive from this talent's own effect-0
// SpellClassMask so behavior matches the original auto-generated
// proc filter exactly (no risk of mask drift across DBC versions).
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
return true;
if (!IsParagonWildcardCaller(GetUnitOwner()))
return false;
// Paragon path: any Frost-school spell that applies a chill effect
// (decrease-speed aura). The spell_proc row already gates by
// SchoolMask=FROST so we only need to verify chill semantics here.
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
if (procSpell->Effects[i].ApplyAuraName == SPELL_AURA_MOD_DECREASE_SPEED)
return true;
}
// Also accept the Improved-Blizzard-style cross-class case where the
// chill is applied by a separate triggered aura: if the proc spell's
// damage hit landed and the target already has a chill from us, treat
// it as eligible. Cheap and matches player expectations for Paragon.
if (Unit* procTarget = eventInfo.GetProcTarget())
{
Unit::AuraEffectList const& slows = procTarget->GetAuraEffectsByType(SPELL_AURA_MOD_DECREASE_SPEED);
for (AuraEffect const* slowEff : slows)
if (slowEff->GetCasterGUID() == GetUnitOwner()->GetGUID())
return true;
}
return false;
}
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_mage_frostbite::CheckProc);
}
};
// 74396 - Fingers of Frost
class spell_mage_fingers_of_frost : public AuraScript
{
@@ -1631,5 +1742,7 @@ void AddSC_mage_spell_scripts()
RegisterSpellScript(spell_mage_polymorph_cast_visual);
RegisterSpellScript(spell_mage_summon_water_elemental);
RegisterSpellScript(spell_mage_fingers_of_frost);
RegisterSpellScript(spell_mage_fingers_of_frost_talent);
RegisterSpellScript(spell_mage_frostbite);
RegisterSpellScript(spell_mage_magic_absorption);
}
+28 -2
View File
@@ -1005,12 +1005,38 @@ class spell_pri_vampiric_embrace : public AuraScript
bool CheckProc(ProcEventInfo& eventInfo)
{
// Not proc from Mind Sear
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
return !(procSpell->SpellFamilyFlags[1] & 0x80000);
// Stock: filter Mind Sear (the damage-tick spell carries this
// SpellFamilyFlags[1] bit; the channel itself is filtered by the
// standard data-row mask). Kept as a bit-test so the stock priest
// path is byte-identical to before this change.
if (procSpell->SpellFamilyFlags[1] & 0x80000)
return false;
// Fractured / Paragon: any single-target Shadow-school damage spell
// procs Vampiric Embrace, not just Priest Shadow spells. The
// SchoolMask=Shadow gate is enforced by the spell_proc data row
// (SchoolMask=32). The data-row family/mask was wildcarded in
// mod-paragon's 2026_05_11_01.sql update so this CheckProc fires for
// cross-family Shadow spells; here we add the single-target
// requirement (Mind Sear was already filtered above; this also
// catches AoE Warlock Shadow spells like Seed of Corruption,
// Hellfire, etc. that a Paragon could otherwise cast).
if (IsParagonWildcardCaller(GetTarget()))
return !procSpell->IsAffectingArea();
// Stock priest path: re-enforce the original Priest Shadow damage
// gate that used to live entirely in the data row. Without this,
// wildcarding the data row would let item-cast Shadow effects
// (consumables, trinkets) accidentally proc VE on stock priests.
if (procSpell->SpellFamilyName != SPELLFAMILY_PRIEST)
return false;
return (procSpell->SpellFamilyFlags[0] & 0x0280A010)
|| (procSpell->SpellFamilyFlags[1] & 0x00002402)
|| (procSpell->SpellFamilyFlags[2] & 0x00000008);
}
void HandleProc(AuraEffect const* aurEff, ProcEventInfo& eventInfo)
@@ -1790,6 +1790,44 @@ class spell_sha_maelstrom_weapon : public AuraScript
});
}
// Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard
// family/mask) so cross-class spells can reach this CheckProc. We
// restore the original Shaman gating here for stock callers and add
// the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist
// mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp.
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock allowlist (Shaman): Lightning Bolt, Chain Lightning,
// Lesser Healing Wave, Healing Wave, Hex. Encoded as the original
// SpellFamilyMask values from the pre-relaxation spell_proc row
// (Mask0 = 451, Mask1 = 32768).
bool stockMatch = procSpell->SpellFamilyName == SPELLFAMILY_SHAMAN
&& ((procSpell->SpellFamilyFlags[0] & 451u)
|| (procSpell->SpellFamilyFlags[1] & 32768u));
if (stockMatch)
return true;
if (!IsParagonWildcardCaller(GetUnitOwner()))
return false;
// Paragon path: also accept the curated Mage cast-time nukes.
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE)
{
SpellInfo const* first = procSpell->GetFirstRankSpell();
uint32 firstId = first ? first->Id : procSpell->Id;
if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/
|| firstId == 30451 /*Arcane Blast*/)
return true;
}
return false;
}
void HandleBonus(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
{
if (GetStackAmount() < int32(GetSpellInfo()->StackAmount))
@@ -1805,6 +1843,7 @@ class spell_sha_maelstrom_weapon : public AuraScript
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_sha_maelstrom_weapon::CheckProc);
OnEffectApply += AuraEffectApplyFn(spell_sha_maelstrom_weapon::HandleBonus, EFFECT_0, SPELL_AURA_ADD_PCT_MODIFIER, AURA_EFFECT_HANDLE_CHANGE_AMOUNT);
}
};