Compare commits

...

5 Commits

Author SHA1 Message Date
Docker Build b8826370c6 Paragon: cross-class stance exclusivity + cancel, hunter ammo soft-fail, Feral Cat scaling, Pestilence DP spread
Server-side batch following v0.7.18, all gated to Paragon (or applied
server-wide where the design discussion called for it):

* Cross-class stance / form / presence / aspect exclusivity (server-wide).
  New `IsFracturedExclusiveStanceSpell()` (`Unit.cpp`/`Unit.h`) returns
  true for the union of warrior stances + druid forms (combat AND utility:
  Travel, Aquatic, Flight, Swift Flight) + Ghost Wolf + base Stealth +
  Shadowform + Metamorphosis + DK Presences + Hunter Aspects (combat AND
  utility: Cheetah, Pack). `Aura::CanStackWith` (`SpellAuras.cpp`) refuses
  to stack two spells from this set, which routes through
  `_RemoveNoStackAurasDueToAura` to drop the older aura -- the same
  mechanism Battle Elixirs / Curses use. Plugs the stock-AC gap where DK
  Presences and Hunter Aspects (regular auras, just rendered in the
  stance bar) coexisted with engine-shapeshifts.

* Stances / Presences / Aspects cancellable like Druid forms.
  `SpellInfoCorrections.cpp` now zeroes `CategoryEntry` on warrior
  stances, DK presences, and every rank of every hunter aspect (moves
  them out of SpellCategory 47 "Combat States", which gates the client's
  right-click / `/cancelaura` path), AND clears `AttributesEx6` bit
  `0x1000` on warrior stances + DK presences (a second client-UI gate
  surfaced via DBC diff -- aspects don't have it set). Mirrored client-
  side by `_patch_spell_dbc_presences_cancelable.py`. Aspects / presences
  do NOT swap action bars (those are owned by `SPELL_AURA_MOD_SHAPESHIFT`,
  not by Category / AttrEx6) -- only warrior stances and druid forms keep
  the bar swap, matching the design requirement that presences/aspects
  not change the player's action bar.

* Hunter ammo soft-fail (server-wide).
  Replaced both `SPELL_FAILED_NO_AMMO` returns in `Spell::CheckCast` with
  `break;` so ranged + thrown abilities cast through with zero ammo;
  `_ApplyAmmoBonuses` continues to gate the actual arrow/bullet DPS bonus
  on a non-empty stack, so equipping ammo still pays off. New programmatic
  `ApplySpellFix`-style block in `SpellInfoCorrections.cpp` iterates every
  Hunter-family spell whose `EquippedItemClass == ITEM_CLASS_WEAPON` and
  `EquippedItemSubClassMask` includes Bow/Gun/Crossbow and sets
  `EquippedItemClass = -1` (skipping a small DENYLIST of Quiver / Ammo
  Pouch passive haste auras + Aynasha's Bow + Legendary Bow Haste -- those
  are item-equip-driven and must keep gating on the ranged weapon being
  equipped). Server log: ">> Fractured: dropped EquippedItemClass on 196
  hunter shot abilities". Mirrored client-side by the new
  `_patch_spell_dbc_hunter_ammo.py` so the 3.3.5a client preflight stops
  blocking the cast packet with "Ammo needs to be in the paper doll ammo
  slot before it can be fired." `Spell::TakeAmmo` no longer clears
  `PLAYER_AMMO_ID` to 0 when the bag empties (defense in depth so a half-
  deployed pair degrades to soft-fail rather than hard-reject). Adds
  `#include "ItemTemplate.h"` for `ITEM_SUBCLASS_WEAPON_*`.

* Feral Cat scaling (server-wide, cat-only).
  `StatSystem.cpp` `UpdateAttackPowerAndDamage` FORM_CAT branch doubles
  the AGI coefficient (1.0 -> 2.0). `SpellAuraEffects.cpp` Master
  Shapeshifter FORM_CAT branch doubles the talent's bp before triggering
  48420 (R1: 2% -> 4% crit, R2: 4% -> 8%). FORM_BEAR / FORM_DIREBEAR /
  FORM_MOONKIN / FORM_TREE branches all left untouched so bear stays
  "already fine" per the resident Feral expert. Client tooltip drift on
  Cat Form (768) + Master Shapeshifter (48411 / 48412) + Pestilence
  (50842) handled by `_patch_spell_dbc_feral_tooltips.py`.

* Pestilence spreads / refreshes Devouring Plague for Paragon casters.
  `spell_dk.cpp` `spell_dk_pestilence::HandleScriptEffect` now also
  spreads (and Glyph-of-Disease refreshes) Priest Devouring Plague when
  `IsParagonWildcardCaller(caster)`. Uses `GetAuraOfRankedSpell(2944)` so
  the spread copy carries the caster's actual rank. Stock DKs cannot
  cast Devouring Plague at all, so this branch is a no-op for them.

* Dancing Rune Weapon: Paragon copies melee, not casts.
  `spell_dk.cpp` `spell_dk_dancing_rune_weapon::CheckProc` for Paragon
  callers requires `eventInfo.GetDamageInfo()` and
  `spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE`, so the ghostly
  weapon now copies cross-class melee strikes (Hamstring, Sinister Strike,
  Heart Strike, Frost Strike, ...) and auto-attacks instead of re-casting
  the DK's nukes. Stock DK gating below is untouched.

* Maelstrom Weapon: drop Arcane Blast from the allowlist.
  `SpellInfo.cpp` and `spell_shaman.cpp` allowlists now match Fireball
  and Frostbolt only -- Arcane Blast stacked with its own self-buff was
  too potent.

* `BALANCE-TODO.md` added under `contrib/fractured-dev-extras/` to
  capture the resident Feral expert's recommendation, the levers we
  considered, and the cat-only Master-Shapeshifter / AGI-doubling
  resolution we shipped, plus the next-lever knobs if field reports
  still flag cat as weak.

DBC patcher pipeline (lives outside the repo in `fractured-tooling/`)
documented run order: runes -> reagents -> stances -> presences_cancelable
-> hunter_ammo -> feral_tooltips -> _make_paragon_dbc_patch.

No SQL migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:17:37 -04:00
Docker Build d1d68cb44a fix(scripts): update server paths for new VPS location
Start script now defaults to /home/fractured-panel/azeroth-server and
passes -c flags so binaries find configs regardless of compiled-in path.
Update script gains --prefix flag to override CMAKE_INSTALL_PREFIX
(persisted to conf/config.sh) during rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 19:44:03 -05:00
Docker Build 999f7e94bd Paragon: narrow weapon bypass; cascade talent ranks; Savage Defense
* Weapon-narrow: replace blanket EquippedItemSubClassMask bypass with an
  explicit allowlist (IsParagonWeaponSubclassWildcardSpell, currently
  Maelstrom Weapon 51528 only). Applied at all three gates: Player::
  HasItemFitToSpellRequirements, Player::CheckAttackFitToAuraRequirement,
  and Aura::IsProcTriggeredOnEvent. Maelstrom Weapon still procs from any
  weapon for Paragon, but Hack and Slash / Sword / Mace Specialization
  remain correctly weapon-gated.
* Talent ability-rank cascade: TeachLevelGatedAbilityChainNoPanel +
  CascadeRanksForTalentLearnSpellEffects walk the SPELL_EFFECT_LEARN_SPELL
  chain a talent rank grants and learn each rank up to the player's level.
  Wired into HandleCommit (on talent purchase) and OnPlayerLevelChanged
  (on level-up). Fixes Mangle (and any future LEARN_SPELL talent) being
  stuck at rank 1 because Player::learnSkillRewardedSpells is intentionally
  disabled for Paragon's class skill lines.
* Riding-skill gate for flight forms (IsParagonSpellAllowedByRidingSkill):
  Flight Form (33943) requires Expert Riding (34090); Swift Flight Form
  (40120) requires Artisan Riding (34091). Applied in PanelLearnSpellChain
  and TeachLevelGatedAbilityChainNoPanel so the cascade can't push past a
  rank the player isn't trained for. Also backticks `rank` in the level-up
  query (MySQL reserved word).
* Savage Defense (62600) on the panel: the SpellData bake's blanket
  SPELL_ATTR0_PASSIVE filter dropped Savage Defense even though Druid
  trainer 33 sells it at level 40. Bake (in fractured-tooling) now carves
  out a small PASSIVE_TRAINER_ALLOWLIST and the regenerated
  paragon_spell_ae_cost.sql + 2026_05_11_05.sql migration surface it on
  the Druid Feral spell tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 19:09:25 -04:00
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
27 changed files with 1697 additions and 93 deletions
@@ -0,0 +1,94 @@
# Fractured / Paragon — Balance Backlog
Open balance / scaling questions surfaced by play-testers that have not yet
been actioned. Each entry should record the *symptom*, the *suspected cause*
based on a quick code dive, the *option set* we discussed, and any *links*
to relevant code. Knock items off as they ship.
---
## Feral Cat scaling feels weak (2026-05-11)
**Reporter feedback:**
> "Weapons don't automatically feature feral AP on this server and nothing
> is currently rescaled resulting in a super low feral scale. We can either
> rescale their abilities or add the AP back to all weapons."
>
> Resident Feral expert: "this is not a bear issue unfortunately, I have
> 11k AP" / "Stam > AP and Armor > AP means this is completely a cat issue."
**What's actually happening on the server:**
Feral AP *is* being granted on weapons — `ItemTemplate::getFeralBonus`
(`src/server/game/Entities/Item/ItemTemplate.h`) synthesises it from the
weapon's DPS for any weapon with `INVTYPE_WEAPON / 2HWEAPON / WEAPONMAINHAND
/ WEAPONOFFHAND`:
```cpp
int32 bonus = int32((extraDPS + getDPS()) * 14.0f) - 767;
```
That's then routed through `Player::ApplyFeralAPBonus` (Player.cpp ~6896)
into `m_baseFeralAP`, which `Player::UpdateAttackPowerAndDamage` adds into
the cat / bear formulas in `src/server/game/Entities/Unit/StatSystem.cpp`
(~line 477):
```cpp
case FORM_CAT:
val2 = (level * mLevelMult) + STR*2 + AGI - 20 + weapon_bonus + m_baseFeralAP;
break;
case FORM_BEAR:
case FORM_DIREBEAR:
val2 = (level * mLevelMult) + STR*2 - 20 + weapon_bonus + m_baseFeralAP;
break;
```
So bear and cat get the same `m_baseFeralAP` — the only delta is `+ AGI` for
cat. The "bears feel fine, cats feel weak" complaint is real; it's because
bear damage is rage / proc / mitigation driven (Lacerate stack, Savage
Defense, Pulverize-style talents) while cat damage is much more
AP-coefficient driven (Shred, Mangle, Rake, Rip, FB).
**Options discussed (pick when we revisit):**
| ID | Lever | Pros | Cons |
|----|-------|------|------|
| A | Bump the cat AP formula (e.g. `+ AGI*1.5`) | Cleanest, very tunable, hits both auto-attacks and abilities | Blunt instrument, also affects PvP |
| B | Boost cat-form ability coefficients (Shred / Rake / Rip / Mangle / FB) via spellmod auras | Most retail-faithful, surgical | More moving parts, harder to communicate |
| C | Increase `getFeralBonus` payout for druid weapons (e.g. `* 18.0f` or drop the `-767` floor) | Single-line change | Buffs bears too — bears are already fine, would over-buff |
| D | New Cat-only passive "Predator's Edge" = `+X% physical damage in Cat Form` | Easy to balance, easy to communicate, easy to undo | Adds another aura to track |
**Recommendation when we pick this back up:** start with **D + small A**
D is the readable "+15-20% cat damage" knob, A is a backup if AP-scaling
abilities (Mangle / FB) still feel weak relative to bleeds. Both are
trivially tunable via a single config knob during play-testing.
Do **not** pick C — it over-buffs bears, which the Feral expert explicitly
said are already fine.
**Resolution (2026-05-11, second pass):** Per the resident Feral expert
("instead of adding a new passive, you could probably just increase Cat
Form's Master Shapeshifter value along with its tooltip, alongside buffing
the agi scaling") we shipped a hybrid of **A** and a *cat-only* knob that
sits next to **D** but reuses an existing aura instead of inventing a new
one:
* `StatSystem.cpp` `UpdateAttackPowerAndDamage` FORM_CAT branch now
reads `+ GetStat(STAT_AGILITY) * 2.0f` (stock 1.0). FORM_BEAR /
FORM_DIREBEAR / FORM_MOONKIN are untouched, so bear's "already fine"
state is preserved.
* `SpellAuraEffects.cpp` Master Shapeshifter FORM_CAT branch multiplies
the talent's value by 2 before triggering 48420 (cat-form aura).
Talent ranks: 2% -> 4% (R1), 4% -> 8% (R2) crit chance in Cat Form.
Bear / Moonkin / Tree branches still pass `bp` through unchanged.
* Client tooltip drift handled by
`fractured-tooling/from-workspace-root/_patch_spell_dbc_feral_tooltips.py`,
which appends a `[Fractured]` paragraph to the Description column of
Cat Form (768) and Master Shapeshifter ranks (48411 / 48412).
If field reports after this lands still say cat is weak, the next levers
are (in order): bump `2.0f` to `2.5f` in StatSystem.cpp, then bump the
Master Shapeshifter cat multiplier from `* 2` to `* 3` in
SpellAuraEffects.cpp, then -- only if those are exhausted -- revisit
**B** (per-ability spellmod coefficients).
@@ -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
+3
View File
@@ -6,6 +6,9 @@ AzerothCore. Upstream AzerothCore does not ship these paths.
Contents:
- BUILD-NATIVE.md — fork-specific native build notes (moved from repo root).
- BALANCE-TODO.md — open balance / scaling questions raised by play-testers
that have not yet been actioned (e.g. Feral Cat scaling). Knock items off
as they ship.
- CLAUDE.md — optional AI assistant context (moved from repo root).
- CLIENT-PATCHES.md — what ships in a Fractured client release (MPQs +
patched Wow.exe), where to download them (Releases page), and how
@@ -472,6 +472,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(61999, 1),
(62078, 1),
(62124, 1),
(62600, 1),
(62757, 1),
(64382, 1),
(64843, 1),
@@ -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);
@@ -0,0 +1,19 @@
-- mod-paragon: surface Savage Defense (62600) on the Druid Feral spell tab
-- of the Character Advancement panel.
--
-- The bake (tools/_gen_paragon_advancement_spells_lua.py) used to drop every
-- SPELL_ATTR0_PASSIVE spell up front, even when the trainer explicitly sells
-- it. That filter was correct for class-internal triggers (Feline Grace, etc.)
-- but kicked out Savage Defense -- a passive that DRUID trainer 33 sells at
-- level 40 (trainer_spell.sql line 2457). Bake now carves out a small
-- PASSIVE_TRAINER_ALLOWLIST so legitimate trainer-taught passives survive.
--
-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was
-- regenerated alongside this migration so fresh deployments already have
-- this row. Existing servers do not re-run base files on content change,
-- so this update inserts the new (spell_id, ae_cost) pair idempotently.
-- INSERT IGNORE keeps any per-row tuning a server operator may have already
-- applied.
INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(62600, 1); -- Savage Defense (Druid, trainer 33, level 40)
+139 -1
View File
@@ -987,6 +987,89 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& out)
}
}
// Riding-skill gating for spells whose effective rank in the spellbook
// depends on the player's flying skill (currently the Druid forms
// Flight Form 33943 / Swift Flight Form 40120). Returns true when the
// player is allowed to learn this specific spell id at this moment.
//
// 33943 (Flight Form, 150% flight) requires 34090 Expert Riding.
// 40120 (Swift Flight Form, 280% flight) requires 34091 Artisan Riding.
//
// Any other spell id is always allowed (returns true). Used by
// `PanelLearnSpellChain` so a Paragon panel purchase / level-up cascade
// silently skips the unaffordable rank but keeps walking the chain --
// e.g. a player with Expert Riding only gets Flight Form, never Swift,
// even though both ranks are in the same SpellChain.dbc graph.
[[nodiscard]] bool IsParagonSpellAllowedByRidingSkill(Player* pl, uint32 spellId)
{
if (!pl)
return true;
if (spellId == 33943)
return pl->HasSpell(34090) || pl->HasSpell(34091); // expert OR artisan
if (spellId == 40120)
return pl->HasSpell(34091); // artisan required for swift
return true;
}
// Walk a rank chain and learn every rank up to the player's current
// level (and not past riding-skill gates), without any of the
// PanelLearnSpellChain panel/AE bookkeeping. Used by talent-grant
// cascades (Mangle / Feral Charge / Mutilate / etc.) where the talent
// LEARN_SPELL effect grants the rank-1 ability and stock would have
// upgraded it via Player::learnSkillRewardedSpells -- but the Paragon
// class-skill cascade is intentionally disabled (Player.cpp guard), so
// nothing else picks up the higher ranks. Idempotent: skips ranks the
// player already has, so safe to re-run on level-up / login.
void TeachLevelGatedAbilityChainNoPanel(Player* pl, uint32 chainHead)
{
if (!pl || !chainHead)
return;
uint32 const playerLevel = pl->GetLevel();
uint32 const firstId = sSpellMgr->GetFirstSpellInChain(chainHead);
uint32 cur = firstId ? firstId : chainHead;
while (cur)
{
SpellInfo const* info = sSpellMgr->GetSpellInfo(cur);
if (!info)
break;
uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info);
if (playerLevel < reqLv)
break;
if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur))
pl->learnSpell(cur, false);
uint32 const next = sSpellMgr->GetNextSpellInChain(cur);
if (!next || next == cur)
break;
cur = next;
}
}
// Walk every SPELL_EFFECT_LEARN_SPELL on `talentRankSpellId` (the
// `TalentEntry::RankID[r]` of a talent rank) and, for each granted
// spell, run TeachLevelGatedAbilityChainNoPanel so the player ends up
// with the highest rank their level can support. Mangle, Feral Charge,
// Mutilate, etc. all fit this pattern.
void CascadeRanksForTalentLearnSpellEffects(Player* pl, uint32 talentRankSpellId)
{
if (!pl || !talentRankSpellId)
return;
SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(talentRankSpellId);
if (!rankInfo)
return;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grantId = rankInfo->Effects[e].TriggerSpell;
if (!grantId)
continue;
TeachLevelGatedAbilityChainNoPanel(pl, grantId);
}
}
// True when `depSpellId` (any rank in its chain) is the target of a
// SPELL_EFFECT_LEARN_SPELL on any rank of the anchor purchase chain
// (`anchorChainHead` is the chain head / panel_spells.spell_id).
@@ -1103,7 +1186,7 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
if (playerLevel < reqLv)
break;
if (!pl->HasSpell(cur))
if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur))
{
std::unordered_set<uint32> before = SnapshotKnownSpells(pl);
if (diag)
@@ -2085,7 +2168,23 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
uint32 const targetRank = std::min<uint32>(currentRank + delta, MAX_TALENT_RANK);
for (uint32 r = currentRank; r < targetRank; ++r)
{
pl->LearnTalent(tid, r, /*command=*/true);
// Fractured / Paragon: talents that LEARN_SPELL an ability
// (Mangle, Feral Charge, Mutilate, ...) only directly grant the
// rank-1 ability spell. Stock classes auto-rank-up the granted
// spell via Player::learnSkillRewardedSpells on level-up, but
// that path is intentionally disabled for Paragon class skill
// lines (Player.cpp guard) -- so without this cascade the
// ability stays at rank 1 forever (Mangle Bear 33878 instead
// of 33986 / 33987 / 48563 / 48564). Walk every LEARN_SPELL
// target on this rank's RankID spell and grant the highest
// rank the player's level allows.
if (TalentEntry const* freshTe = sTalentStore.LookupEntry(tid))
if (uint32 rankSpell = freshTe->RankID[r])
CascadeRanksForTalentLearnSpellEffects(pl, rankSpell);
}
}
for (auto const& [tid, delta] : talentDeltas)
@@ -4013,6 +4112,45 @@ public:
ReconcileEssenceForPlayer(player);
SaveCurrencyToDb(player);
PushCurrency(player);
// Fractured / Paragon: rank-up cascade for level-up. Without this,
// higher ranks of panel-purchased spells AND talent-LEARN_SPELL
// granted abilities (Mangle, Feral Charge, Mutilate, ...) never
// appear on ding because Player::learnSkillRewardedSpells is
// disabled for the class skill line on Paragon (intentional, to
// keep the panel as the sole authority over class abilities).
//
// Cheap re-walks: PanelLearnSpellChain / TeachLevelGated... both
// skip ranks the player already has, so the only real work each
// level-up is adding the one new rank the player just qualified
// for (if any).
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do
{
uint32 const head = r->Fetch()[0].Get<uint32>();
PanelLearnSpellChain(player, head);
} while (r->NextRow());
}
if (QueryResult tr = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
{
do
{
Field* f = tr->Fetch();
uint32 const talentId = f[0].Get<uint32>();
uint32 const rank = f[1].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(talentId);
if (!te)
continue;
uint32 const cap = std::min<uint32>(rank, MAX_TALENT_RANK);
for (uint32 i = 0; i < cap; ++i)
if (uint32 rankSpell = te->RankID[i])
CascadeRanksForTalentLearnSpellEffects(player, rankSpell);
} while (tr->NextRow());
}
}
bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* talent, uint32 /*rank*/) override
+8 -5
View File
@@ -7,13 +7,15 @@
# AZEROTH_BIN=/path/to/azeroth-server/bin bash scripts/start-azeroth-servers.sh
#
# Environment:
# AZEROTH_BIN — directory with authserver and worldserver (default: /root/azeroth-server/bin)
# AZEROTH_BIN — directory with authserver and worldserver (default: /home/fractured-panel/azeroth-server/bin)
# AZEROTH_LOG_DIR — log directory (default: <parent of bin>/logs)
set -euo pipefail
BIN_DIR="${AZEROTH_BIN:-/root/azeroth-server/bin}"
LOG_DIR="${AZEROTH_LOG_DIR:-$(cd "$(dirname "$BIN_DIR")" && pwd)/logs}"
BIN_DIR="${AZEROTH_BIN:-/home/fractured-panel/azeroth-server/bin}"
BASE_DIR="$(cd "$(dirname "$BIN_DIR")" && pwd)"
LOG_DIR="${AZEROTH_LOG_DIR:-${BASE_DIR}/logs}"
CONF_DIR="${BASE_DIR}/etc"
AUTH_BIN="${BIN_DIR}/authserver"
WORLD_BIN="${BIN_DIR}/worldserver"
@@ -35,15 +37,16 @@ mkdir -p "$LOG_DIR"
cd "$BIN_DIR"
nohup "$AUTH_BIN" >>"$LOG_DIR/authserver.log" 2>&1 &
nohup "$AUTH_BIN" -c "${CONF_DIR}/authserver.conf" >>"$LOG_DIR/authserver.log" 2>&1 &
disown || true
sleep 2
nohup "$WORLD_BIN" >>"$LOG_DIR/worldserver.log" 2>&1 &
nohup "$WORLD_BIN" -c "${CONF_DIR}/worldserver.conf" >>"$LOG_DIR/worldserver.log" 2>&1 &
disown || true
echo "Started authserver and worldserver (survives SSH disconnect)."
echo "Bin: $BIN_DIR"
echo "Config: $CONF_DIR"
echo "Logs: $LOG_DIR/authserver.log"
echo " $LOG_DIR/worldserver.log"
+21
View File
@@ -35,6 +35,7 @@ FULL_BUILD=0
COMPILE_ONLY=0
DRY_RUN=0
DO_RUN_AFTER=0
INSTALL_PREFIX=""
POST_UPDATE_CMD="${FRACTURED_POST_UPDATE_CMD:-}"
GIT_REMOTE="${FRACTURED_GIT_REMOTE:-origin}"
@@ -49,6 +50,7 @@ Options:
--no-pull Skip git pull (only compile current tree).
--full ./acore.sh compiler all (clean + configure + compile).
--compile-only ./acore.sh compiler compile (incremental).
--prefix PATH Override CMAKE_INSTALL_PREFIX (updates conf/config.sh BINPATH).
--dry-run Print commands without running them.
--run-after [CMD] Run shell command after successful compile. If CMD is omitted,
uses FRACTURED_POST_UPDATE_CMD from the environment.
@@ -87,6 +89,15 @@ while [[ $# -gt 0 ]]; do
COMPILE_ONLY=1
shift
;;
--prefix)
shift
if [[ $# -eq 0 || "$1" == -* ]]; then
echo "error: --prefix requires a path argument" >&2
exit 2
fi
INSTALL_PREFIX="$1"
shift
;;
--dry-run)
DRY_RUN=1
shift
@@ -129,6 +140,16 @@ fi
cd "$ROOT"
if [[ -n "$INSTALL_PREFIX" ]]; then
echo "==> updating conf/config.sh BINPATH to: $INSTALL_PREFIX"
if grep -q '^BINPATH=' conf/config.sh; then
run sed -i "s|^BINPATH=.*|BINPATH=\"$INSTALL_PREFIX\"|" conf/config.sh
else
echo "BINPATH=\"$INSTALL_PREFIX\"" >> conf/config.sh
fi
export BINPATH="$INSTALL_PREFIX"
fi
if [[ "$DO_RUN_AFTER" -eq 1 && -z "${POST_UPDATE_CMD// }" ]]; then
echo "error: --run-after needs a command or FRACTURED_POST_UPDATE_CMD set in the environment." >&2
exit 2
+39 -2
View File
@@ -7143,6 +7143,18 @@ bool Player::CheckAttackFitToAuraRequirement(WeaponAttackType attackType, AuraEf
if (spellInfo->EquippedItemClass == -1)
return true;
// Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass
// gate ONLY for the curated allowlist of cross-class proc talents
// (currently just Maelstrom Weapon 51528-51532). Weapon-specialization
// talents like Sword Specialization, Mace Specialization, Hack and
// Slash, Two-Handed Weapon Specialization etc. deliberately stay
// weapon-gated for Paragon -- the player picks a weapon and the
// matching specialization passive activates, same as any class.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(this)
&& IsParagonWeaponSubclassWildcardSpell(spellInfo->Id))
return GetWeaponForAttack(attackType, true) != nullptr;
Item* item = GetWeaponForAttack(attackType, true);
if (!item || !item->IsFitToSpellRequirements(spellInfo))
return false;
@@ -7208,7 +7220,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 +7240,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 +12593,31 @@ bool Player::HasItemFitToSpellRequirements(SpellInfo const* spellInfo, Item cons
if (spellInfo->EquippedItemClass < 0)
return true;
// Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass
// gate ONLY for the curated allowlist of cross-class proc talents
// (currently just Maelstrom Weapon 51528-51532) so the passive talent
// aura attaches when the player wields a non-stock weapon. Weapon-
// specialization talents (Sword/Mace Specialization, Hack and Slash,
// Two-Handed Weapon Specialization, etc.) deliberately stay weapon-
// gated -- they're meant to bind to a specific weapon type. Still
// requires *some* weapon equipped (unarmed Paragons don't auto-activate
// weapon talents). ITEM_CLASS_ARMOR (shield) is left alone -- shield-
// gated talents still need an actual shield.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(this)
&& IsParagonWeaponSubclassWildcardSpell(spellInfo->Id))
{
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)
+19 -1
View File
@@ -474,7 +474,25 @@ void Player::UpdateAttackPowerAndDamage(bool ranged)
switch (GetShapeshiftForm())
{
case FORM_CAT:
val2 = (GetLevel() * mLevelMult) + GetStat(STAT_STRENGTH) * 2.0f + GetStat(STAT_AGILITY) - 20.0f + weapon_bonus + m_baseFeralAP;
// Fractured: Cat Form gets 2 AP per Agility instead of stock 1.
// Field reports said "weapons dont automatically feature feral
// AP on this server and nothing is currently rescaled, super
// low feral scale" -- specifically a CAT issue, not a bear
// issue (the resident bear had 11k AP, the resident cat was
// miles behind because Stam > AP and Armor > AP for bears
// hides the missing weapon-AP for them but cat's whole
// mainline is melee crits scaling off AP). The cleanest knob
// that does NOT touch bear is the AGI multiplier in this
// switch -- bears get STR*2 with no AGI term, so doubling
// the AGI coefficient lifts cat's primary scaling stat
// without re-buffing bear. Also pairs with the cat-form
// Master Shapeshifter buff in SpellAuraEffects.cpp's
// FORM_CAT branch (bp doubled there). Together that lands
// the resident Feral expert's recommendation
// ("instead of adding a new passive, you could probably
// just increase Cat Form's Master Shapeshifter value along
// with its tooltip, alongside buffing the agi scaling").
val2 = (GetLevel() * mLevelMult) + GetStat(STAT_STRENGTH) * 2.0f + GetStat(STAT_AGILITY) * 2.0f - 20.0f + weapon_bonus + m_baseFeralAP;
break;
case FORM_BEAR:
case FORM_DIREBEAR:
+149 -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,12 +98,117 @@
// 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;
}
bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId)
{
if (!spellId)
return false;
// Resolve to the first-rank id so callers can pass any rank.
uint32 firstRankId = spellId;
if (SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = info->GetFirstRankSpell())
firstRankId = first->Id;
switch (firstRankId)
{
// Maelstrom Weapon (talent ranks 51528 / 51529 / 51530 / 51531 / 51532).
// Cross-class proc talent that should fire off any equipped weapon
// for a Paragon caster (1H sword, polearm, staff, fist, dagger, etc.).
case 51528:
return true;
default:
return false;
}
}
bool IsFracturedExclusiveStanceSpell(uint32 spellId)
{
if (!spellId)
return false;
// Resolve to the first-rank id so callers can pass any rank. This means
// every rank of Aspect of the Hawk / Wild / Pack / Dragonhawk is covered
// by listing only the rank-1 id below; same for druid forms that have
// multiple ranks via talent (none in WotLK actually, but kept consistent).
uint32 firstRankId = spellId;
if (SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = info->GetFirstRankSpell())
firstRankId = first->Id;
switch (firstRankId)
{
// -- Warrior stances (engine-shapeshifts; engine already mutually
// excludes them with each other and with druid forms via
// AuraEffect::HandleAuraModShapeshift's RemoveAurasByType, but we
// list them here so they participate in the union with presences /
// aspects).
case 2457: // Battle Stance
case 71: // Defensive Stance
case 2458: // Berserker Stance
// -- Druid combat forms (engine-shapeshifts).
case 5487: // Bear Form
case 9634: // Dire Bear Form
case 768: // Cat Form
case 24858: // Moonkin Form
case 33891: // Tree of Life Form
// -- Druid utility forms (engine-shapeshifts; included per design
// decision 2026-05-11 -- player must drop Travel/Aquatic/Flight to
// apply Hawk / Frost Presence / Berserker Stance, and vice versa).
case 783: // Travel Form
case 1066: // Aquatic Form
case 33943: // Flight Form
case 40120: // Swift Flight Form
// -- Shaman utility form (engine-shapeshift FORM_GHOSTWOLF).
case 2645: // Ghost Wolf
// -- Rogue base stealth (engine-shapeshift FORM_STEALTH). Shadow
// Dance (51713) is intentionally NOT listed -- it is a 6s
// stealth-burst on a 60s CD, gating it would defeat its purpose.
case 1784: // Stealth
// -- Priest combat form (engine-shapeshift FORM_SHADOW).
case 15473: // Shadowform
// -- Warlock combat form (engine-shapeshift FORM_METAMORPHOSIS).
case 47241: // Metamorphosis
// -- Death Knight Presences. NOT engine-shapeshifts in stock AC --
// they are regular auras that the client just renders in the
// stance bar -- which is exactly why stock DK can stack them on
// top of Bear Form / Defensive Stance / Aspect of the Hawk on a
// Paragon character. Listing them here is what plugs the gap.
case 48266: // Blood Presence
case 48263: // Frost Presence
case 48265: // Unholy Presence
// -- Hunter Aspects (combat). Like presences, these are regular
// auras stock AC, not engine-shapeshifts; rank-1 ids cover all
// ranks via GetFirstRankSpell. Cheetah / Pack are the utility
// aspects -- included per design decision so a hunter must pick
// between Hawk and Cheetah (no more "always Hawk while running",
// matches Ascension's nerf rationale for Monkey).
case 13165: // Aspect of the Hawk (rank 1; ranks 14318/14319/14320/14321/14322/25296/27044 covered via first-rank)
case 5118: // Aspect of the Cheetah
case 13159: // Aspect of the Pack (rank 1; rank 27047 covered via first-rank)
case 20043: // Aspect of the Wild (rank 1; ranks 20190/27045 covered via first-rank)
case 13161: // Aspect of the Beast
case 13163: // Aspect of the Monkey
case 34074: // Aspect of the Viper
case 61846: // Aspect of the Dragonhawk (rank 1; rank 61847 covered via first-rank)
return true;
default:
return false;
}
}
float baseMoveSpeed[MAX_MOVE_TYPE] =
{
2.5f, // MOVE_WALK
@@ -6136,17 +6254,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)
+51
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
@@ -2277,6 +2287,47 @@ private:
// beyond what they already include via Unit.h's transitive headers.
[[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily);
// Fractured / Paragon: returns true iff `spellId` is on the curated allowlist
// of cross-class proc talents whose `EquippedItemSubClassMask` should be
// bypassed for Paragon casters (e.g. Maelstrom Weapon ranks 51528-51532).
// Used by `Player::HasItemFitToSpellRequirements`,
// `Player::CheckAttackFitToAuraRequirement`, and
// `Aura::IsProcTriggeredOnEvent` to let these specific talents fire from
// any equipped weapon while keeping weapon-specialization talents
// (Sword Specialization, Mace Specialization, Hack and Slash, Two-Handed
// Weapon Specialization, etc.) gated by their stock weapon-class mask.
// The allowlist is matched against `SpellInfo::GetFirstRankSpell()`'s id
// so all ranks of a talent are covered by listing the rank-1 id.
[[nodiscard]] bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId);
// Fractured: returns true iff `spellId` is one of the cross-class
// "stance-like" auras that we treat as mutually exclusive on this server,
// regardless of the caster's class. Stock AC engine-shapeshifts (warrior
// stances, druid forms, Shadowform, Metamorphosis) already auto-replace
// each other via `RemoveAurasByType(SPELL_AURA_MOD_SHAPESHIFT)` in
// `AuraEffect::HandleAuraModShapeshift`, but DK Presences and Hunter
// Aspects are regular auras (the client just renders them in the stance
// bar), so they coexist with shapeshifts in stock AC. The Fractured rule
// makes the entire union mutually exclusive: warrior stances + druid
// forms (combat AND utility -- Travel/Aquatic/Flight/Swift Flight) +
// Ghost Wolf + Stealth + Shadowform + Metamorphosis + DK Presences +
// Hunter Aspects (combat AND utility -- Cheetah/Pack). Casting any of
// them removes any other from the same set, so e.g. a Paragon DK can no
// longer stack Frost Presence on top of Bear Form, and a hunter must
// pick between Hawk and Cheetah even out of combat.
//
// The set is matched against `SpellInfo::GetFirstRankSpell()`'s id so
// every rank of every aspect / form is covered by listing the rank-1 id.
// Server-wide -- this is *not* gated on CLASS_PARAGON because the only
// stock-class-only effect of the rule (a DK losing Travel Form when
// they cast Frost Presence) is impossible: stock DKs cannot shapeshift.
// Used by `Aura::CanStackWith` to refuse stacking, which then drives
// `Unit::_RemoveNoStackAurasDueToAura` to drop the older aura -- the
// same mechanism Battle Elixirs / Curses already use (spell_group rule
// SPELL_GROUP_STACK_RULE_EXCLUSIVE), implemented in C++ here so we do
// not have to enumerate every rank of every aspect / form in SQL.
[[nodiscard]] bool IsFracturedExclusiveStanceSpell(uint32 spellId);
namespace Acore
{
// Binary predicate for sorting Units based on percent value of a power
@@ -1545,7 +1545,21 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
// Master Shapeshifter - Cat
if (AuraEffect const* aurEff = target->GetDummyAuraEffect(SPELLFAMILY_GENERIC, 2851, 0))
{
int32 bp = aurEff->GetAmount();
// Fractured: cat-only Master Shapeshifter bonus is
// doubled (rank 1: 2% -> 4%, rank 2: 4% -> 8%) to
// make Feral Cat builds feel less "super low feral
// scale" without touching bear / moonkin / tree (the
// FORM_BEAR / FORM_MOONKIN / FORM_TREE branches
// below pass `bp` straight through, unchanged). The
// talent's own SpellInfo Effects[].BasePoints is
// intentionally NOT bumped -- aurEff->GetAmount()
// returns the per-rank talent value (2 / 4) shared
// across all four forms, so we apply the cat
// multiplier here at the cast site, leaving every
// other form on the stock value. Pairs with the
// Cat-Form AGI doubling in StatSystem.cpp's
// UpdateAttackPowerAndDamage FORM_CAT branch.
int32 bp = aurEff->GetAmount() * 2;
target->CastCustomSpell(target, 48420, &bp, nullptr, nullptr, true);
}
break;
@@ -1630,7 +1644,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);
}
}
+41 -1
View File
@@ -1973,6 +1973,31 @@ bool Aura::CanStackWith(Aura const* existingAura) const
|| (sameCaster && m_spellInfo->IsAuraExclusiveBySpecificPerCasterWith(existingSpellInfo)))
return false;
// Fractured: cross-class stance / form / presence / aspect exclusivity.
// Stock AC's engine-shapeshift removal in HandleAuraModShapeshift only
// covers warrior stances, druid forms, Shadowform, Metamorphosis, etc.
// -- DK Presences and Hunter Aspects are regular auras (the client just
// happens to render them in the stance bar) and therefore stack with
// engine-shapeshifts in stock AC. The Fractured rule (server-wide --
// see IsFracturedExclusiveStanceSpell in Unit.h for the curated set
// and the design rationale) treats the union of stances + forms (combat
// AND utility) + presences + aspects as mutually exclusive. Refusing
// to stack here triggers the same _RemoveNoStackAurasDueToAura cleanup
// path that Battle Elixirs / Curses already use, so the older aura
// drops and the newly-cast one applies cleanly. Different ranks of the
// same talent (e.g. Hawk rank 4 -> Hawk rank 7) are NOT treated as
// exclusive with each other -- IsFracturedExclusiveStanceSpell resolves
// to first-rank ids, so we compare those.
if (IsFracturedExclusiveStanceSpell(m_spellInfo->Id) && IsFracturedExclusiveStanceSpell(existingSpellInfo->Id))
{
SpellInfo const* newFirst = m_spellInfo->GetFirstRankSpell();
SpellInfo const* oldFirst = existingSpellInfo->GetFirstRankSpell();
uint32 newFirstId = newFirst ? newFirst->Id : m_spellInfo->Id;
uint32 oldFirstId = oldFirst ? oldFirst->Id : existingSpellInfo->Id;
if (newFirstId != oldFirstId)
return false;
}
// check spell group stack rules
switch (sSpellMgr->CheckSpellGroupStackRules(m_spellInfo, existingSpellInfo))
{
@@ -2251,8 +2276,23 @@ 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 the
// weapon-subclass gate ONLY for the curated allowlist of
// cross-class proc talents (currently just Maelstrom Weapon
// 51528-51532). Weapon-specialization talents (Sword/Mace
// Specialization, Hack and Slash, Two-Handed Weapon
// Specialization, etc.) deliberately stay weapon-gated for
// Paragon. Restricted to ITEM_CLASS_WEAPON so shield-gated
// talents still need an actual shield.
if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(target)
&& IsParagonWeaponSubclassWildcardSpell(GetSpellInfo()->Id)))
return 0;
}
}
}
+90 -61
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;
@@ -7711,56 +7732,64 @@ SpellCastResult Spell::CheckItems(uint32* param1, uint32* param2)
switch (pItem->GetTemplate()->SubClass)
{
case ITEM_SUBCLASS_WEAPON_THROWN:
{
uint32 ammo = pItem->GetEntry();
if (!m_caster->ToPlayer()->HasItemCount(ammo))
return SPELL_FAILED_NO_AMMO;
};
{
// Fractured: thrown abilities behave like DK runes -- they
// remain usable even when the player has run out of the
// throwing item. Stock AC returned SPELL_FAILED_NO_AMMO
// here; we just drop the gate. Spell::TakeAmmo's stack
// decrement is wrapped in a HasItemCount check via
// DestroyItemCount and will silently no-op at zero. The
// ranged-DPS bonus naturally vanishes when the stack runs
// out, so the player still throws but loses the per-shot
// damage contribution from the throwing item.
break;
};
case ITEM_SUBCLASS_WEAPON_GUN:
case ITEM_SUBCLASS_WEAPON_BOW:
case ITEM_SUBCLASS_WEAPON_CROSSBOW:
{
uint32 ammo = m_caster->ToPlayer()->GetUInt32Value(PLAYER_AMMO_ID);
if (!ammo)
{
// Requires No Ammo
if (m_caster->HasAura(46699))
break; // skip other checks
return SPELL_FAILED_NO_AMMO;
}
ItemTemplate const* ammoProto = sObjectMgr->GetItemTemplate(ammo);
if (!ammoProto)
return SPELL_FAILED_NO_AMMO;
if (ammoProto->Class != ITEM_CLASS_PROJECTILE)
return SPELL_FAILED_NO_AMMO;
// check ammo ws. weapon compatibility
switch (pItem->GetTemplate()->SubClass)
{
case ITEM_SUBCLASS_WEAPON_BOW:
case ITEM_SUBCLASS_WEAPON_CROSSBOW:
if (ammoProto->SubClass != ITEM_SUBCLASS_ARROW)
return SPELL_FAILED_NO_AMMO;
break;
case ITEM_SUBCLASS_WEAPON_GUN:
if (ammoProto->SubClass != ITEM_SUBCLASS_BULLET)
return SPELL_FAILED_NO_AMMO;
break;
default:
return SPELL_FAILED_NO_AMMO;
}
if (!m_caster->ToPlayer()->HasItemCount(ammo))
{
m_caster->ToPlayer()->SetUInt32Value(PLAYER_AMMO_ID, 0);
return SPELL_FAILED_NO_AMMO;
}
};
{
// Fractured: ranged abilities behave like DK runes -- they
// remain usable when the player has no ammo loaded or the
// quiver / pouch is empty. The DPS-bonus path (StatSystem.cpp:
// `weaponMin/MaxDamage += GetAmmoDPS() * attackSpeedMod`)
// reads `m_ammoDPS`, which is 0 when no ammo is loaded and
// recomputed via Player::_ApplyAmmoBonuses on equip / stack
// exhaustion, so a hunter with an empty bag still casts
// Steady Shot / Aimed Shot etc. -- they just lose the arrow
// / bullet DPS contribution.
//
// We deliberately do NOT clear PLAYER_AMMO_ID when the bag
// empties. Defense in depth alongside the data-side fix:
//
// * The primary client-side fix lives in Spell.dbc --
// SpellInfoCorrections.cpp's "drop EquippedItemClass on
// hunter shot abilities" block (mirrored client-side by
// fractured-tooling/_patch_spell_dbc_hunter_ammo.py)
// sets EquippedItemClass = -1 on every player-castable
// hunter shot, which removes the 3.3.5a client's
// "ranged weapon AND ammo slot non-empty" preflight
// gate entirely. After that, ammo is purely a
// server-side DPS bonus, never a hard requirement.
//
// * Keeping the (now-stale) ammo id in PLAYER_AMMO_ID
// field is harmless: TakeAmmo's DestroyItemCount
// silently no-ops when HasItemCount is 0, and
// _ApplyAmmoBonuses already recomputes m_ammoDPS to 0
// when the proto can no longer be found / the stack is
// empty. So the StatSystem.cpp ammo-DPS path gracefully
// degrades to "no bonus" the moment the bag goes empty.
//
// * Player un-equipping the ammo via the paper-doll
// right-click still routes through RemoveAmmo() and
// zeroes the field -- that is the player's explicit
// action and we leave it alone.
//
// Net result: hunter has bow + ammo -> full DPS; bow only ->
// shots still fire, no ammo DPS; no bow -> client engine's
// own ranged-weapon gate still blocks (Auto Shot timer
// simply never spins up without a ranged weapon equipped).
break;
};
case ITEM_SUBCLASS_WEAPON_WAND:
break;
default:
+70 -2
View File
@@ -1378,7 +1378,43 @@ 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 to benefit from
// the cast-time + cost reduction spellmod. Arcane Blast was on
// the allowlist briefly but proved too potent stacked with its
// own self-buff -- removed.
if (SpellFamilyName == SPELLFAMILY_MAGE)
{
SpellInfo const* first = GetFirstRankSpell();
uint32 firstId = first ? first->Id : Id;
if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/)
return true;
}
break;
}
default:
break;
}
}
return false;
}
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
@@ -1463,7 +1499,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 +1507,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;
@@ -18,6 +18,7 @@
#include "DBCStores.h"
#include "DBCStructure.h"
#include "GameGraveyard.h"
#include "ItemTemplate.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
@@ -5380,6 +5381,118 @@ void SpellMgr::LoadSpellInfoCorrections()
spellInfo->Attributes |= SPELL_ATTR0_PASSIVE;
});
// Fractured: move Death Knight Presences and Hunter Aspects out of
// SpellCategory 47 ("Combat States") so they cancel/toggle the same
// way Druid shapeshift forms do.
//
// Category 47 is the "stance bar" category. The 3.3.5a client UI
// explicitly disables right-click-cancel and `/cancelaura <name>` for
// any aura whose Spell.dbc Category column points at a SpellCategory
// entry that is "Combat States" (47). Druid forms (Bear Form, Cat
// Form, Travel Form, Moonkin, Tree of Life, etc.) sit in Category 0
// and are therefore freely cancellable -- right-click drops the form,
// /cancelaura drops it, recasting from the action bar drops it.
// Warrior stances, DK Presences and Hunter Aspects all live in
// Category 47, which is why none of them are cancellable in stock.
//
// For the cross-class stance / form / presence / aspect exclusivity
// rule (see IsFracturedExclusiveStanceSpell in Unit.cpp), a Paragon
// hybrid often wants to drop their active presence/aspect so they can
// apply a different stance/form *without* first switching to a
// different presence/aspect. Setting Category to 0 here mirrors what
// Druid forms already do, gives the cancel/toggle UX the user
// explicitly asked for, and -- importantly -- does NOT change the
// action bar (presences and aspects are not engine-shapeshifts, the
// bar swap behavior is owned by SPELL_AURA_MOD_SHAPESHIFT, not by
// SpellCategory). The matching client-side Spell.dbc edit ships in
// patch-enUS-4.MPQ via _patch_spell_dbc_presences_cancelable.py.
//
// Warrior stances are also included per design decision 2026-05-11
// ("you could make Warrior Stances toggleable as well, it should be
// okay"). The previously-shipped Stances=0 client patch already lets
// Paragon non-warriors cast every warrior ability without picking up
// a stance, so a stock warrior who right-clicks their stance just
// ends up at "no stance" -- which on this server still leaves all
// their warrior abilities available. Stock warriors who like the
// never-cancel UX can simply not right-click; nothing forces them.
//
// Tradeoff: stances / presences / aspects lose the 1s SpellCategory
// GCD that Category 47 enforces between same-category spells. This
// matches the Druid-form UX (Bear -> Cat -> Bear has no shared GCD),
// and the cross-class exclusivity rule in Aura::CanStackWith already
// prevents stacking, so the only thing actually possible at "0s GCD"
// is rapid-toggling the same stance on and off, which is harmless.
ApplySpellFix({
// Warrior Stances.
2457, // Battle Stance
71, // Defensive Stance
2458, // Berserker Stance
// Death Knight Presences.
48266, // Blood Presence
48263, // Frost Presence
48265, // Unholy Presence
// Hunter Aspects -- every rank, since AC stores the per-rank
// SpellInfo as separate objects and `Category` lives on each.
// Rank-1 ids are the same ones listed in
// IsFracturedExclusiveStanceSpell; trailing ids are higher ranks.
13165, 14318, 14319, 14320, 14321, 14322, 25296, 27044, // Aspect of the Hawk r1..r8
5118, // Aspect of the Cheetah
13159, // Aspect of the Pack (only one rank in WotLK; 27047 is "Growl", do NOT add)
20043, 20190, 27045, // Aspect of the Wild r1..r3
13161, // Aspect of the Beast
13163, // Aspect of the Monkey
34074, // Aspect of the Viper
61846, 61847, // Aspect of the Dragonhawk r1..r2
}, [](SpellInfo* spellInfo)
{
spellInfo->CategoryEntry = nullptr;
});
// Fractured: clear AttributesEx6 bit 0x1000 on Warrior Stances and DK
// Presences so the 3.3.5 client UI lets right-click and `/cancelaura`
// drop them, the same way Druid forms / Hunter Aspects already cancel.
//
// Empirical finding (see fractured-tooling/inspect_stance_attr6.py for
// the diff script): when only `SpellCategory` is cleared (the Combat-
// States gate at column 1), Hunter Aspects become cancellable but
// Warrior Stances and DK Presences still aren't. Diffing the Spell.dbc
// rows of working vs broken stance-bar buffs across patched-Aspects and
// unpatched-Stances/Presences identifies a SECOND gating column:
// `AttributesEx6` (col 10) bit `0x1000`. It is set on every Warrior
// Stance (Battle/Defensive/Berserker) and every DK Presence
// (Blood/Frost/Unholy) but NOT on any Hunter Aspect (and not on Druid
// forms / Ghost Wolf / Stealth / Shadowform). Clearing the bit removes
// the secondary client-UI gate without changing how the action bar /
// shapeshift system works (those are owned by SPELL_AURA_MOD_SHAPESHIFT,
// not by attribute bits).
//
// AC names this bit `SPELL_ATTR6_ALLOW_WHILE_RIDING_VEHICLE`. That name
// is from a different role of the same bit -- when set on a regular
// ability, AC's `Spell::CheckCast` vehicle-passenger gate uses it to
// grant "this spell is castable from a vehicle seat". Stripping it from
// Warrior Stances / DK Presences is harmless because those aren't cast
// from vehicle seats anyway (the player is `IsCharmed()` in a seat and
// the stance / presence wouldn't apply meaningfully). The matching
// client-side Spell.dbc edit ships in patch-enUS-4.MPQ via
// _patch_spell_dbc_presences_cancelable.py.
//
// Hunter Aspects intentionally NOT included -- their AttributesEx6 is
// already 0 (or 0x04000000 for Pack/Wild, which is a different bit
// unrelated to cancel gating), and listing them here would be a no-op.
ApplySpellFix({
2457, // Battle Stance
71, // Defensive Stance
2458, // Berserker Stance
48266, // Blood Presence
48263, // Frost Presence
48265, // Unholy Presence
}, [](SpellInfo* spellInfo)
{
spellInfo->AttributesEx6 &= ~SPELL_ATTR6_ALLOW_WHILE_RIDING_VEHICLE;
});
// Fractured: strip reagent requirements from every player-class spell at
// load time. Filtered by SpellFamilyName != 0 so that profession spells
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
@@ -5418,6 +5531,76 @@ void SpellMgr::LoadSpellInfoCorrections()
LOG_INFO("server.loading", ">> Fractured: cleared reagents on {} class spells", fixedClassSpells);
}
// Fractured: drop EquippedItemClass on hunter shot abilities at load time
// so the server agrees with the matching client-side Spell.dbc patch
// (fractured-tooling/_patch_spell_dbc_hunter_ammo.py). Both surfaces have
// to agree -- if only the client patch shipped, the server's stock
// EquippedItemClass check would still reject mid-cast; if only the server
// mirror shipped, the 3.3.5a client preflight would still block the cast
// packet from leaving the box with "Ammo needs to be in the paper doll
// ammo slot before it can be fired." The Spell::CheckCast soft-fail
// (Spell.cpp 7741..) and the never-clear-PLAYER_AMMO_ID change there are
// still in place as defense in depth so a half-deployed client / server
// pair degrades to the soft-fail behavior rather than to hard rejects.
//
// Filter mirrors the Python patcher byte-for-byte:
// SpellFamilyName == SPELLFAMILY_HUNTER (9)
// AND EquippedItemClass == ITEM_CLASS_WEAPON (2)
// AND EquippedItemSubClassMask & ((1<<BOW)|(1<<GUN)|(1<<XBOW)) != 0
// with a small DENYLIST of item-equip-driven passive auras (Quiver /
// Ammo Pouch haste ranks, Legendary Bow Haste, Aynasha's Bow proc) whose
// entire purpose is "have a ranged weapon equipped" -- those keep their
// stock EquippedItemClass = 2.
//
// Effect: after this fix, hunter shots leave the client preflight without
// hitting the ammo-slot gate AND pass the server's EquippedItemClass
// check unconditionally. _ApplyAmmoBonuses still gates the arrow / bullet
// DPS bonus on actually having a stack in the quiver, so equipping ammo
// continues to give the DPS bump and an empty quiver no longer bricks
// abilities -- "you still get the DPS increase from arrows but aren't
// completely neutered if you run out", per the resident hunter expert.
{
constexpr uint32 RANGED_SUBCLASS_MASK =
(1u << ITEM_SUBCLASS_WEAPON_BOW)
| (1u << ITEM_SUBCLASS_WEAPON_GUN)
| (1u << ITEM_SUBCLASS_WEAPON_CROSSBOW);
// Keep in sync with DENYLIST in
// fractured-tooling/_patch_spell_dbc_hunter_ammo.py.
static const std::unordered_set<uint32> hunterAmmoDenylist = {
// Quiver / Ammo Pouch ranged-attack-speed haste passives (gun).
14824, 14825, 14826, 14827, 14828, 14829,
// Quiver passive haste (bow + crossbow).
29413, 29414, 29415, 29416, 29417, 29418,
// Late-rank quiver haste, gun-only.
44333,
// Legendary Bow Haste (item proc on a specific bow).
44972,
// Aynasha's Bow item proc.
19767,
};
uint32 fixedShots = 0;
for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId)
{
SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId);
if (!info || info->SpellFamilyName != SPELLFAMILY_HUNTER)
continue;
if (info->EquippedItemClass != ITEM_CLASS_WEAPON)
continue;
if (info->EquippedItemSubClassMask <= 0
|| (uint32(info->EquippedItemSubClassMask) & RANGED_SUBCLASS_MASK) == 0)
continue;
if (hunterAmmoDenylist.find(spellId) != hunterAmmoDenylist.end())
continue;
SpellInfo* mut = const_cast<SpellInfo*>(info);
mut->EquippedItemClass = -1;
++fixedShots;
}
LOG_INFO("server.loading", ">> Fractured: dropped EquippedItemClass on {} hunter shot abilities", fixedShots);
}
LOG_INFO("server.loading", ">> Loading spell dbc data corrections in {} ms", GetMSTimeDiffToNow(oldMSTime));
LOG_INFO("server.loading", " ");
}
+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()));
}
}
};
+41
View File
@@ -664,6 +664,23 @@ class spell_dk_dancing_rune_weapon : public AuraScript
return false;
SpellInfo const* spellInfo = eventInfo.GetSpellInfo();
// Fractured / Paragon: Paragon owners get a "ghostly weapon copies
// your swings" identity instead of the stock magical-doppelganger
// (which also re-cast Death Coil / Icy Touch / Howling Blast /
// etc.). For Paragon callers only, accept auto-attacks and
// melee-class abilities (Hamstring, Sinister Strike, Heart Strike,
// Frost Strike, Mortal Strike, ...) and reject magic / ranged
// spells. Stock DK gating below is left untouched.
if (IsParagonWildcardCaller(eventInfo.GetActor()))
{
if (!eventInfo.GetDamageInfo())
return false;
if (spellInfo && spellInfo->DmgClass != SPELL_DAMAGE_CLASS_MELEE)
return false;
return true;
}
if (!spellInfo)
return true;
@@ -1916,6 +1933,20 @@ class spell_dk_pestilence : public SpellScript
if (!target)
return;
// Fractured / Paragon: when the Pestilence caster is a Paragon and
// wildcard family matching is on, also spread (or refresh) Priest
// Devouring Plague. Devouring Plague's Dispel field is DISPEL_DISEASE
// and Unit::GetDiseasesByCaster already counts it for Paragon callers
// (see Unit.cpp), so it is conceptually a disease; stock Pestilence
// just hard-codes Blood Plague + Frost Fever and so silently drops it.
// GetAuraOfRankedSpell with the rank-1 id (2944) covers every rank of
// Devouring Plague the player has on the target -- we re-cast that
// exact same rank so the spread copy carries the caster's actual
// damage tier rather than always rank 1. Stock DKs cannot cast
// Devouring Plague at all, so the GetAuraOfRankedSpell will return
// null for them and this branch is a no-op there.
bool const paragonSpread = IsParagonWildcardCaller(caster);
// Spread on others
if (target != hitUnit)
{
@@ -1926,6 +1957,11 @@ class spell_dk_pestilence : public SpellScript
// Frost Fever
if (target->GetAura(SPELL_DK_FROST_FEVER, caster->GetGUID()))
caster->CastSpell(hitUnit, SPELL_DK_FROST_FEVER, true);
// Fractured / Paragon: Devouring Plague spread.
if (paragonSpread)
if (Aura const* dp = target->GetAuraOfRankedSpell(2944 /* Devouring Plague r1 */, caster->GetGUID()))
caster->CastSpell(hitUnit, dp->GetId(), true);
}
// Refresh on target
else if (caster->GetAura(SPELL_DK_GLYPH_OF_DISEASE))
@@ -1946,6 +1982,11 @@ class spell_dk_pestilence : public SpellScript
disease->RefreshDuration();
else if (Aura* disease = target->GetAuraOfRankedSpell(SPELL_DK_CRYPT_FEVER_R1, caster->GetGUID()))
disease->RefreshDuration();
// Fractured / Paragon: Devouring Plague Glyph-of-Disease refresh.
if (paragonSpread)
if (Aura* dp = target->GetAuraOfRankedSpell(2944 /* Devouring Plague r1 */, caster->GetGUID()))
dp->RefreshDuration();
}
}
+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,45 @@ 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 allowlist mirroring the
// IsAffectedBySpellMod hook in SpellInfo.cpp. Arcane Blast was on the
// allowlist briefly but proved too potent stacked with its own
// self-buff -- removed.
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*/)
return true;
}
return false;
}
void HandleBonus(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
{
if (GetStackAmount() < int32(GetSpellInfo()->StackAmount))
@@ -1805,6 +1844,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);
}
};