Paragon: Runeforging support (panel-purchasable, no anvil required)

Lets the Paragon class buy Runeforging from the Character Advancement
panel and apply rune enchants from anywhere in the world without needing
to be near a runeforge GameObject. Three carve-outs work together:

* Spell.cpp: bypass the SpellFocusObject GO proximity check when the
  caster is a Paragon and the spell belongs to SKILL_RUNEFORGING (776).
  Stock DK behaviour is unchanged -- the bypass is gated on
  getClass() == CLASS_PARAGON, not on the IsClass() context hook.

* Player.cpp: skip the Paragon class-skill cascade block for skill 776
  so the rune-enchant SLA cascade actually fires. Without this the
  player gets the Runeforging skill but no rune options at the anvil.

* Paragon_Essence.cpp:
  - Treat SKILL_RUNEFORGING children as a meta-skill cluster: cascade
    them like passives even though they're active casts, so they stick
    as panel_spell_children and get cleaned up via the standard refund
    path.
  - Whitelist the 8 basic rune-enchants in PruneSkillLineCascadeChildren
    so they don't get evicted as "active in children = legacy garbage".
  - Force-attach them in PanelLearnSpellChain (the SLA rows ship with
    AcquireMethod=0, so the engine cascade alone won't grant them).
  - Add an OnPlayerLogin fixup so existing Paragons who bought
    Runeforging before this change get the 8 runes retro-granted.
  - Stop filtering SPELL_ATTR0_DO_NOT_DISPLAY in PushSpellSnapshot --
    Runeforging itself is hidden in the DBC but is a real panel
    purchase that must show in the Overview tab.

The two advanced runes (Stoneskin Gargoyle, Nerubian Carapace) are
intentionally excluded from the auto-grant -- retail gates them behind
heroic dungeon / raid item drops and the SLA AcquireMethod=0 honours
that gating.

No SQL migration needed; works against existing DBC + SLA data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-12 04:49:38 -04:00
parent b8826370c6
commit da17074a63
3 changed files with 220 additions and 8 deletions
+176 -7
View File
@@ -584,6 +584,37 @@ void RevokeBlockedSpellsForPlayer(Player* pl)
continue;
}
// Same migration for meta-skill cascade spells. Earlier builds
// (and this one until just now) revoked the rune-enchant spells
// (Razorice, Cinderglacier, Rune of the Fallen Crusader, ...)
// when a Paragon learned Runeforging via the panel, because
// they're active spells and the default classifier treats
// unknown active cascades as leaks. New policy: anything on
// SKILL_RUNEFORGING is part of the Runeforging meta-skill
// package and stays. Drop the revoked row and, if we have a
// still-owned parent (typically Runeforging itself, 53428),
// re-record as a child so refund/unlearn still cleans them up.
bool isMetaSkillRevoke = false;
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(sid);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
isMetaSkillRevoke = true;
break;
}
}
}
if (isMetaSkillRevoke)
{
if (parent && ownedPanelSpells.count(parent))
passiveMigrate.emplace_back(parent, sid);
passiveStaleAll.push_back(sid);
++migrated;
continue;
}
if (allowed.count(sid))
{
stale.push_back(sid);
@@ -753,11 +784,57 @@ void RevokeBlockedSpellsForPlayer(Player* pl)
return false;
}
// Allowlist for ACTIVE spells we explicitly want kept as
// panel_spell_children, even though the general policy is "actives in
// children = legacy garbage, drop them" (see
// PruneSkillLineCascadeChildrenFromDb).
//
// The original kAttached set was 100% passives (Frost Fever, Blood
// Plague, Forceful Deflection, Runic Focus). For those, "passive ==
// keep" was a perfect proxy. Runeforging changed that: the 8 basic
// rune-enchant spells (53344, 53343, 53341, 53331, 53342, 53323,
// 54447, 54446) are ACTIVE casts that we DO want to attach to the
// Runeforging panel purchase so:
// * The Lua-substitute Runeforge UI can cast them (HasActiveSpell).
// * Refunding Runeforging cleans them up via the standard
// panel_spell_children unlearn path.
//
// Without this allowlist, PruneSkillLineCascadeChildrenFromDb runs
// immediately after PanelLearnSpellChain attaches them, sees them as
// non-passive, drops them, and inserts panel_spell_revoked rows --
// stranding the player with no usable runeforging menu.
//
// Every entry here MUST also appear in PanelLearnSpellChain::kAttached
// AND in OnPlayerLogin's kFixup list (or a shared source if those ever
// get factored out). The pair ordering is (parentHead, attachedSpell),
// matching kAttached / kFixup.
struct IntentionalActiveAttached { uint32 parent; uint32 child; };
static IntentionalActiveAttached const kIntentionalActiveAttached[] = {
{ 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Rune of Razorice
{ 53428, 53341 }, // Runeforging -> Rune of Cinderglacier
{ 53428, 53331 }, // Runeforging -> Rune of Lichbane
{ 53428, 53342 }, // Runeforging -> Rune of Spellshattering
{ 53428, 53323 }, // Runeforging -> Rune of Swordshattering
{ 53428, 54447 }, // Runeforging -> Rune of Spellbreaking
{ 53428, 54446 }, // Runeforging -> Rune of Swordbreaking
};
[[nodiscard]] static bool IsIntentionalActiveAttachedChild(uint32 parent, uint32 child)
{
for (auto const& e : kIntentionalActiveAttached)
if (e.parent == parent && e.child == child)
return true;
return false;
}
// Current policy: cascade-granted passives stick as panel_spell_children;
// only actives get revoked. This pass exists to scrub *legacy* rows that
// older logic inserted incorrectly — specifically, any active spell that
// ended up in panel_spell_children from a build that classified things
// differently. Passive children are always retained.
// differently. Passive children are always retained, as are entries
// whitelisted via kIntentionalActiveAttached (Runeforging rune-enchants
// are active casts that we deliberately attach as children).
static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid)
{
if (!pl)
@@ -776,6 +853,8 @@ static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid)
SpellInfo const* info = sSpellMgr->GetSpellInfo(child);
if (info && info->IsPassive())
continue; // passives always stay
if (IsIntentionalActiveAttachedChild(parent, child))
continue; // intentional active attachment
// Active in children -> legacy garbage. Drop the row, revoke the
// spell, and persist into panel_spell_revoked so the login sweep
// catches future cascade re-fires.
@@ -1228,13 +1307,45 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
if (!dep)
continue;
if (dep->IsPassive())
// Meta-skill cascade carve-out. Runeforging (776) is a
// CLASS-category skill that, once granted, is supposed to
// cascade ALL its rune-enchant spells (Rune of the Fallen
// Crusader, Razorice, Cinderglacier, Lichbane, Spell-/
// Sword-shattering, Spell-/Sword-breaking, Stoneskin
// Gargoyle, Nerubian Carapace) for the player to choose
// from at a runeforge anvil. Those rune-enchants are
// ACTIVE spells, so the default policy below would
// revoke them and the player would learn Runeforging
// for nothing. Treat the whole cluster the same way we
// treat passive deps: persist as children of the panel
// purchase so refund/unlearn drops them too, but do NOT
// revoke them.
//
// Detection: walk the dep's own SkillLineAbility entries
// and check for SKILL_RUNEFORGING. This auto-handles all
// 10 rune-enchant spells without an ID-by-ID allowlist.
bool isMetaSkillCascade = false;
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
isMetaSkillCascade = true;
break;
}
}
}
if (dep->IsPassive() || isMetaSkillCascade)
{
DbInsertPanelSpellChild(lowGuid, trackId, spellId);
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} (passive dep, kept as child of {})",
spellId, trackId);
"[paragon-diag] +{} ({} dep, kept as child of {})",
spellId,
isMetaSkillCascade ? "meta-skill" : "passive",
trackId);
}
else
{
@@ -1301,6 +1412,34 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
{ 45477, 61455 }, // Icy Touch -> Runic Focus (parry from spell power)
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection (parry from strength)
// Runeforging -> 8 basic rune-enchants. The
// SkillLineAbility rows for these (skill 776) all ship
// with AcquireMethod = 0 in the DBC (i.e. NOT auto-learn-
// on-skill-grant). For stock DKs the engine's hardcoded
// runeforging UI hand-rolls the cast for whichever rune
// the player picks, but for our Lua-substitute UI the
// server's HandleCastSpellOpcode / HasActiveSpell gate
// rejects the cast unless the spell is in the spellbook.
// Force-attach them as panel children so:
// 1. The player actually owns the spells (cast works).
// 2. Refunding Runeforging cleans them up via the
// standard panel_spell_children unlearn path.
// The two ADVANCED runes (Stoneskin Gargoyle 62158 and
// Nerubian Carapace 70164) are intentionally NOT listed:
// retail gates them behind item drops from heroic
// dungeons / Naxx / ICC, and our SkillLineAbility rows
// for them already use AcquireMethod=0 so the player
// gets them when they pick up the appropriate item, not
// for free with Runeforging itself.
{ 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Rune of Razorice
{ 53428, 53341 }, // Runeforging -> Rune of Cinderglacier
{ 53428, 53331 }, // Runeforging -> Rune of Lichbane
{ 53428, 53342 }, // Runeforging -> Rune of Spellshattering
{ 53428, 53323 }, // Runeforging -> Rune of Swordshattering
{ 53428, 54447 }, // Runeforging -> Rune of Spellbreaking
{ 53428, 54446 }, // Runeforging -> Rune of Swordbreaking
};
// Self-heal: a previous build of mod-paragon (briefly shipped)
@@ -2256,9 +2395,21 @@ void PushSpellSnapshot(Player* pl)
continue;
}
SpellInfo const* info = sSpellMgr->GetSpellInfo(sid);
if (info && info->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY))
continue;
// Note: we deliberately do NOT filter SPELL_ATTR0_DO_NOT_DISPLAY
// here. Earlier builds did, on the theory that hidden spells
// shouldn't appear in the spellbook-style Overview tab. That
// turned out to be wrong: cascade-granted hidden passives
// (Forceful Deflection, Frost Fever, ...) live in
// panel_spell_children, not in panel_spells -- so the only
// entries that ever land in this query are the chain heads
// the player explicitly purchased. Those MUST appear in the
// Overview even if their DBC entry is hidden, because they
// are the player's actual purchases (e.g. Runeforging 53428
// is hidden in the DBC but is the entire Runeforging panel
// purchase). Filtering them out left chars whose only buy
// was Runeforging with an empty Overview tab -- looked like
// a regression but was actually the existing snapshot logic
// mismatching the panel's user-facing semantics.
std::string token = (first ? "" : ",") + std::to_string(sid);
if (buf.size() + token.size() > kSnapshotChunkBudget)
@@ -4018,6 +4169,24 @@ public:
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive)
{ 45477, 61455 }, // Icy Touch -> Runic Focus
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection
// Runeforging -> 8 basic rune-enchants. Mirror of
// PanelLearnSpellChain::kAttached: the SLA rows for
// these (skill 776) ship with AcquireMethod=0 so the
// engine's normal cascade never grants them, and for
// the substitute Lua runeforging UI to actually be
// able to cast them HasActiveSpell needs to return
// true. Existing Paragon characters that bought
// Runeforging before this fix landed get them
// retro-granted on their next login.
{ 53428, 53344 }, // Runeforging -> Fallen Crusader
{ 53428, 53343 }, // Runeforging -> Razorice
{ 53428, 53341 }, // Runeforging -> Cinderglacier
{ 53428, 53331 }, // Runeforging -> Lichbane
{ 53428, 53342 }, // Runeforging -> Spellshattering
{ 53428, 53323 }, // Runeforging -> Swordshattering
{ 53428, 54447 }, // Runeforging -> Spellbreaking
{ 53428, 54446 }, // Runeforging -> Swordbreaking
};
for (auto const& lf : kFixup)
{
+14 -1
View File
@@ -12048,7 +12048,20 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
// weapon, language, and racial skill cascades stay enabled so things
// like recipe auto-learn, weapon proficiencies, and racial perks
// still work.
if (getClass() == CLASS_PARAGON)
//
// Carve-out: SKILL_RUNEFORGING (776) is a CLASS-category skill but
// behaves like a profession in this context — the player buys ONE
// panel ability (Runeforging, spell 53428) and the rune-enchant
// spells (Rune of the Fallen Crusader, Razorice, Cinderglacier, ...)
// are supposed to come along for the ride via the standard SLA
// cascade, exactly the same way they do for a stock DK. Without
// this carve-out, the early-return below blocks the cascade and a
// Paragon who buys Runeforging gets the skill but no actual rune
// options at the runeforge anvil. The cascade only fires once per
// skill-grant for 776 (it's not on UpdateSkillsForLevel) so the
// "leaking back into the spellbook" concern that motivates the
// early-return doesn't apply to this skill.
if (getClass() == CLASS_PARAGON && skill_id != SKILL_RUNEFORGING)
{
if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id))
if (sl->categoryId == SKILL_CATEGORY_CLASS)
+30
View File
@@ -7860,6 +7860,36 @@ SpellCastResult Spell::CheckSpellFocus()
// check spell focus object
if (m_spellInfo->RequiresSpellFocus)
{
// Fractured / Paragon: skip the GO proximity check for Paragon
// casters when the spell is a runeforge enchant (skill line 776).
// Paragons get a Runeforge tab in the Character Advancement
// panel that lets them apply rune-enchants from anywhere in the
// world -- no need to fly back to Acherus or find the nearest
// Eastern Plaguelands anvil. The skill-line gate keeps the
// bypass tightly scoped: only the 10 SkillLineAbility-tagged
// rune-enchant spells qualify, every other spell that uses
// SpellFocusObject (Enchanting bench, Cooking fire, Lockpicking
// anvil, etc.) keeps its requirement intact.
//
// DK / other class casters are unchanged -- this carve-out
// intentionally checks getClass() == CLASS_PARAGON rather than
// IsClass(CLASS_DEATH_KNIGHT, CLASS_CONTEXT_ABILITY) (which
// would also return true for Paragons via mod-paragon's hook).
// Stock DK behavior must stay vanilla; the QoL bypass is a
// class-12 feature only.
if (m_caster && m_caster->IsPlayer()
&& m_caster->ToPlayer()->getClass() == CLASS_PARAGON)
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(m_spellInfo->Id);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
return SPELL_CAST_OK;
}
}
}
CellCoord p(Acore::ComputeCellCoord(m_caster->GetPositionX(), m_caster->GetPositionY()));
Cell cell(p);