diff --git a/contrib/fractured-dev-extras/BALANCE-TODO.md b/contrib/fractured-dev-extras/BALANCE-TODO.md new file mode 100644 index 0000000..1a856fa --- /dev/null +++ b/contrib/fractured-dev-extras/BALANCE-TODO.md @@ -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). diff --git a/contrib/fractured-dev-extras/README.txt b/contrib/fractured-dev-extras/README.txt index ab84350..4a0be81 100644 --- a/contrib/fractured-dev-extras/README.txt +++ b/contrib/fractured-dev-extras/README.txt @@ -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 diff --git a/src/server/game/Entities/Unit/StatSystem.cpp b/src/server/game/Entities/Unit/StatSystem.cpp index c66aa3e..ad0fa2c 100644 --- a/src/server/game/Entities/Unit/StatSystem.cpp +++ b/src/server/game/Entities/Unit/StatSystem.cpp @@ -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: diff --git a/src/server/game/Entities/Unit/Unit.cpp b/src/server/game/Entities/Unit/Unit.cpp index 43c0aec..49d2cc5 100644 --- a/src/server/game/Entities/Unit/Unit.cpp +++ b/src/server/game/Entities/Unit/Unit.cpp @@ -126,6 +126,89 @@ bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId) } } +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 diff --git a/src/server/game/Entities/Unit/Unit.h b/src/server/game/Entities/Unit/Unit.h index f82fc0f..ed5a633 100644 --- a/src/server/game/Entities/Unit/Unit.h +++ b/src/server/game/Entities/Unit/Unit.h @@ -2300,6 +2300,34 @@ private: // 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 diff --git a/src/server/game/Spells/Auras/SpellAuraEffects.cpp b/src/server/game/Spells/Auras/SpellAuraEffects.cpp index 9af2455..ba55952 100644 --- a/src/server/game/Spells/Auras/SpellAuraEffects.cpp +++ b/src/server/game/Spells/Auras/SpellAuraEffects.cpp @@ -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; diff --git a/src/server/game/Spells/Auras/SpellAuras.cpp b/src/server/game/Spells/Auras/SpellAuras.cpp index 030a089..41df92b 100644 --- a/src/server/game/Spells/Auras/SpellAuras.cpp +++ b/src/server/game/Spells/Auras/SpellAuras.cpp @@ -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)) { diff --git a/src/server/game/Spells/Spell.cpp b/src/server/game/Spells/Spell.cpp index f2a4706..f9f276e 100644 --- a/src/server/game/Spells/Spell.cpp +++ b/src/server/game/Spells/Spell.cpp @@ -7732,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: diff --git a/src/server/game/Spells/SpellInfo.cpp b/src/server/game/Spells/SpellInfo.cpp index 43f8215..89286cc 100644 --- a/src/server/game/Spells/SpellInfo.cpp +++ b/src/server/game/Spells/SpellInfo.cpp @@ -1395,15 +1395,16 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* liste { case 53817: // Shaman: Maelstrom Weapon { - // Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to - // benefit from the cast-time + cost reduction spellmod. + // 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*/ - || firstId == 30451 /*Arcane Blast*/) + || firstId == 116 /*Frostbolt*/) return true; } break; diff --git a/src/server/game/Spells/SpellInfoCorrections.cpp b/src/server/game/Spells/SpellInfoCorrections.cpp index 50cf941..2e1587b 100644 --- a/src/server/game/Spells/SpellInfoCorrections.cpp +++ b/src/server/game/Spells/SpellInfoCorrections.cpp @@ -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 ` 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< 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(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", " "); } diff --git a/src/server/scripts/Spells/spell_dk.cpp b/src/server/scripts/Spells/spell_dk.cpp index 577c7d2..27966fb 100644 --- a/src/server/scripts/Spells/spell_dk.cpp +++ b/src/server/scripts/Spells/spell_dk.cpp @@ -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(); } } diff --git a/src/server/scripts/Spells/spell_shaman.cpp b/src/server/scripts/Spells/spell_shaman.cpp index 25101e7..ffa3514 100644 --- a/src/server/scripts/Spells/spell_shaman.cpp +++ b/src/server/scripts/Spells/spell_shaman.cpp @@ -1793,8 +1793,10 @@ class spell_sha_maelstrom_weapon : public AuraScript // Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard // family/mask) so cross-class spells can reach this CheckProc. We // restore the original Shaman gating here for stock callers and add - // the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist - // mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp. + // 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(); @@ -1820,8 +1822,7 @@ class spell_sha_maelstrom_weapon : public AuraScript SpellInfo const* first = procSpell->GetFirstRankSpell(); uint32 firstId = first ? first->Id : procSpell->Id; if (firstId == 133 /*Fireball*/ - || firstId == 116 /*Frostbolt*/ - || firstId == 30451 /*Arcane Blast*/) + || firstId == 116 /*Frostbolt*/) return true; }