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>
This commit is contained in:
Docker Build
2026-05-11 14:54:05 -04:00
parent a1c9172beb
commit e649402163
16 changed files with 874 additions and 39 deletions
+2 -2
View File
@@ -7208,7 +7208,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 +7228,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
}
+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);
}
}
+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);
}
};