Paragon: server-authoritative CP/rune sync + cascade-spell revoke hardening

mod-paragon Paragon_Essence.cpp:
- Broaden SkillLinesLinkedToSpell: collect every SkillLineAbility row for
  an anchor spell regardless of AcquireMethod, so anchor spells whose
  primary SLA uses AcquireMethod 0 (e.g. Blood Strike) correctly identify
  their skill lines and let the dependent classifier do its job.
- IsSpellSkillLineCascadeDependent / RevokeUnwantedCascadeSpellsForPlayer
  use the broadened helper. HandleCommit calls the post-purchase sweep
  immediately so the spellbook never carries lingering cascade dependents
  (Blood Presence / Forceful Deflection / Death Coil / Death Grip).
- New character_paragon_panel_spell_revoked table tracks which active
  dependents we've revoked per (guid, parent) so OnPlayerLogin can
  re-revoke them after AC's _LoadSkills -> learnSkillRewardedSpells
  silently re-grants them.
- OnPlayerLogin opens the client SILENCE window via SendSilenceOpenForCommit
  with an empty allow list and intentionally omits the matching
  SendSilenceClose: the chat frame buffers CHAT_MSG_SYSTEM during the
  loading screen and only flushes after PLAYER_ENTERING_WORLD, so a paired
  CLOSE would shut the filter before the buffered "you have unlearned X"
  toasts hit it. The addon's 8s fail-open closes the window after the flush.
- New `.paragon hat` chat command for diagnosing Honor Among Thieves
  triggers (talent rank, learned spell, applied aura, proc table entry).

mod-paragon Paragon_SC.cpp:
- OnPlayerUpdate pushes server-authoritative combo points to the client
  via PARAA "R CP <n>" whenever the count changes. The client-side
  ComboFrame Paragon simulator listens for this and updates the target
  frame, fixing HAT-generated CP not displaying (HAT's trigger casts
  with a null target, which the combat-log inference path can't see).
- OnPlayerUpdate also pushes "R RUNES <cd0..cd5>" (ms remaining per
  rune slot) on rune mask changes, so the client RuneFrame simulator
  stays in lock-step with Spell::TakeRunePower instead of drifting
  through combat-log latency.

mod-paragon SQL:
- New updates/2026_05_09_00.sql migration creates
  character_paragon_panel_spell_revoked for AC's auto-DBUpdater so a
  fresh checkout can stand up an existing characters DB without
  manual intervention. Matching CREATE TABLE IF NOT EXISTS in
  base/character_paragon_panel_learned.sql for fresh installs.

mod-paragon conf:
- New Paragon.Diag.PanelLearn flag traces every PanelLearnSpellChain
  commit (chain ids, before/after spell-map sizes, side-spell
  classification) for diagnosing "spell reappears on relog" bugs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-09 11:22:57 -04:00
parent 81df32963f
commit 4d2a80ddb8
5 changed files with 676 additions and 16 deletions
+581 -12
View File
@@ -16,15 +16,18 @@
#include "RBAC.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "SpellAuras.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
#include "WorldDatabase.h"
#include "WorldPacket.h"
#include "Log.h"
#include "DBCEnums.h"
#include "DBCStores.h"
#include <fmt/format.h>
#include <algorithm>
#include <array>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
@@ -254,6 +257,10 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
void PushSnapshot(Player* pl);
// Forward declaration: the login-time scoped sweep (defined a few helpers
// down) calls into the chain-walker (defined further down).
void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& out);
// ---- Panel-learn tracking (Overview + resets) ------------------------------
//
// Spells / talent ranks bought through Character Advancement are stored in
@@ -276,6 +283,282 @@ void DbInsertPanelSpellChild(uint32 lowGuid, uint32 parentSpellId, uint32 childS
lowGuid, parentSpellId, childSpellId);
}
// Persist an "active dependent we revoked" so we can re-revoke it after
// AC's skill cascade re-grants it on the next login. See
// `RevokeBlockedSpellsForPlayer` for the redo step.
void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revokedSpellId)
{
CharacterDatabase.DirectExecute(
"INSERT IGNORE INTO character_paragon_panel_spell_revoked "
"(guid, parent_spell_id, revoked_spell_id) VALUES ({}, {}, {})",
lowGuid, parentSpellId, revokedSpellId);
}
// Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the
// player still has it. Call sites:
// * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells`
// ran during LoadFromDB and may have re-granted Blood Presence /
// Death Coil / Death Grip / etc. before any of our hooks fired.
// * `HandleParagonResetAbilities` is NOT a caller; reset clears the
// table outright so the revoke list starts fresh on next purchase.
void RevokeBlockedSpellsForPlayer(Player* pl)
{
if (!pl)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
QueryResult r = CharacterDatabase.Query(
"SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}",
lowGuid);
if (!r)
return;
uint32 removed = 0;
do
{
uint32 const sid = r->Fetch()[0].Get<uint32>();
if (pl->HasSpell(sid))
{
pl->removeSpell(sid, SPEC_MASK_ALL, false);
++removed;
}
} while (r->NextRow());
if (removed)
LOG_INFO("module",
"Paragon panel: re-revoked {} skill-cascade dependents for {} on login",
removed, pl->GetName());
}
[[nodiscard]] static bool SkillLineAbilityIsSkillCascadeSigned(SkillLineAbilityEntry const* sla)
{
return sla && (sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_LEARN
|| sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_VALUE);
}
// SkillLine ids from every SkillLineAbility row that lists `spellId`
// (any AcquireMethod). Used as the *anchor* side of cascade detection.
//
// Important: the old helper only kept rows with AcquireMethod
// LEARNED_ON_SKILL_* . Spells like Blood Strike are usually tied to
// their skill line via a trainer/default row (AcquireMethod 0). That
// meant the anchor set was empty, so `IsSpellSkillLineCascadeDependent`
// never matched Forceful Deflection / Blood Presence — those were stored
// as innocent "children" and never revoked.
//
// Dependent detection still requires the *dependent* spell to have a
// LEARNED_ON_SKILL_* row on one of these lines (same as
// `learnSkillRewardedSpells`).
[[nodiscard]] static std::unordered_set<uint32> SkillLinesLinkedToSpell(uint32 spellId)
{
std::unordered_set<uint32> out;
SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
for (auto it = bounds.first; it != bounds.second; ++it)
{
SkillLineAbilityEntry const* sla = it->second;
if (!sla)
continue;
out.insert(sla->SkillLine);
}
return out;
}
// True when `depSpellId` is granted as a skill-line reward on one of the
// same SkillLines as `anchorSpellId` (e.g. Blood Strike -> Forceful
// Deflection / Blood Presence). Passives learned only via spell effects
// (disease auras, etc.) typically return false here.
[[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId)
{
std::unordered_set<uint32> const anchorLines = SkillLinesLinkedToSpell(anchorSpellId);
if (anchorLines.empty())
return false;
SkillLineAbilityMapBounds db = sSpellMgr->GetSkillLineAbilityMapBounds(depSpellId);
for (auto it = db.first; it != db.second; ++it)
{
SkillLineAbilityEntry const* sla = it->second;
if (!SkillLineAbilityIsSkillCascadeSigned(sla))
continue;
if (anchorLines.count(sla->SkillLine))
return true;
}
return false;
}
// Older builds recorded skill-line cascade passives (Forceful Deflection,
// Runic Focus, ...) as `panel_spell_children`. Strip those rows and
// revoke the spell so the login sweep + reset logic match current policy.
static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid)
{
if (!pl)
return;
QueryResult r = CharacterDatabase.Query(
"SELECT parent_spell_id, child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}",
lowGuid);
if (!r)
return;
do
{
uint32 const parent = r->Fetch()[0].Get<uint32>();
uint32 const child = r->Fetch()[1].Get<uint32>();
if (!IsSpellSkillLineCascadeDependent(parent, child))
continue;
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}",
lowGuid, parent, child);
if (pl->HasSpell(child))
{
pl->removeSpell(child, SPEC_MASK_ALL, false);
DbInsertPanelSpellRevoked(lowGuid, parent, child);
}
} while (r->NextRow());
}
// Login-time scoped sweep for cascade-granted dependents that the
// commit-time diff missed.
//
// Background: AC's `_addSpell` calls `LearnDefaultSkill(skill, 0)` once
// for any SkillLineAbility-linked spell when the player doesn't yet have
// the skill. `LearnDefaultSkill` -> `SetSkill` -> `learnSkillRewardedSpells`
// then grants every reward of that skill as PLAYERSPELL_TEMPORARY (which
// lives in m_spells but is NOT written to character_spell). On every
// subsequent login `_LoadSkills` re-fires the cascade for any skill row
// that exists in `character_skills`, silently re-granting Blood Presence
// / Forceful Deflection / Death Coil / etc. -- including for characters
// like Test whose first commit predates the per-purchase revoke list.
//
// Strategy: from the player's panel-purchased spells, derive the set of
// SkillLines we've activated (via SkillLineAbility's SkillLine field).
// Walk those SkillLines' rewards and revoke any spell (active or passive)
// currently in m_spells that isn't in our allowlist (panel chain ranks +
// non-cascade passive children). Persist each revoke into
// `character_paragon_panel_spell_revoked` so the cheaper
// `RevokeBlockedSpellsForPlayer` path handles it on every subsequent
// login.
//
// Why this is safe (unlike the earlier blanket-temporary sweep):
// * Only walks SkillLines that we caused to be activated. Racial
// skills, weapon skills, Defense, etc. live in different SkillLines
// and are never reached.
// * Passives that are pure spell-effect side effects stay in
// `character_paragon_panel_spell_children` and remain allowlisted.
// * Skill-line cascade passives (Forceful Deflection, ...) are not
// allowlisted children anymore; see `PruneSkillLineCascadeChildrenFromDb`
// + `PanelLearnSpellChain`.
void RevokeUnwantedCascadeSpellsForPlayer(Player* pl)
{
if (!pl)
return;
bool const diag = sConfigMgr->GetOption<bool>("Paragon.Diag.PanelLearn", false);
uint32 const lowGuid = pl->GetGUID().GetCounter();
PruneSkillLineCascadeChildrenFromDb(pl, lowGuid);
// Build the allowlist: every chain rank of every panel-purchased spell,
// plus every recorded passive child.
std::unordered_set<uint32> allowed;
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}",
lowGuid))
{
do
{
uint32 const base = r->Fetch()[0].Get<uint32>();
CollectSpellChainIds(base, allowed);
} while (r->NextRow());
}
if (QueryResult r = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}",
lowGuid))
{
do
{
allowed.insert(r->Fetch()[0].Get<uint32>());
} while (r->NextRow());
}
if (allowed.empty())
return;
// From the allowlist, derive which SkillLines we've activated. Use
// every SkillLineAbility row for each spell (not only LEARNED_ON_SKILL_*),
// matching `SkillLinesLinkedToSpell` / `_addSpell`'s LearnDefaultSkill path.
std::unordered_set<uint32> ourSkillLines;
for (uint32 spellId : allowed)
{
SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
for (auto it = bounds.first; it != bounds.second; ++it)
{
SkillLineAbilityEntry const* sla = it->second;
if (!sla)
continue;
ourSkillLines.insert(sla->SkillLine);
}
}
if (ourSkillLines.empty())
return;
// For each activated SkillLine, walk its rewards and queue revokes for
// active spells that aren't allowlisted but are currently in m_spells.
std::vector<uint32> toRevoke;
for (uint32 skillLine : ourSkillLines)
{
for (SkillLineAbilityEntry const* ab : GetSkillLineAbilitiesBySkillLine(skillLine))
{
if (!ab)
continue;
uint32 const sid = ab->Spell;
if (!sid)
continue;
if (allowed.count(sid))
continue;
if (!pl->HasSpell(sid))
continue; // not in m_spells right now
SpellInfo const* info = sSpellMgr->GetSpellInfo(sid);
if (!info)
continue;
toRevoke.push_back(sid);
}
}
if (toRevoke.empty())
{
if (diag)
LOG_INFO("module",
"[paragon-diag] login sweep: no orphan cascade spells for {} "
"(skillLines scanned={}, allowlist={})",
pl->GetName(), ourSkillLines.size(), allowed.size());
return;
}
// Dedup (a spell can show up under multiple SkillLines).
std::sort(toRevoke.begin(), toRevoke.end());
toRevoke.erase(std::unique(toRevoke.begin(), toRevoke.end()), toRevoke.end());
for (uint32 sid : toRevoke)
{
pl->removeSpell(sid, SPEC_MASK_ALL, false);
// parent_spell_id = 0 -> "caught at login by scoped sweep, no
// specific parent". Distinct PK from the (guid, parent, sid)
// rows the commit-time diff inserts.
DbInsertPanelSpellRevoked(lowGuid, 0u, sid);
}
LOG_INFO("module",
"Paragon panel: scoped sweep revoked {} cascade spells for {} "
"(skillLines scanned={}, allowlist={})",
toRevoke.size(), pl->GetName(), ourSkillLines.size(), allowed.size());
}
// Snapshot of currently-known spell IDs (excluding entries marked removed).
// Used by PanelLearnSpellChain to detect spells that AzerothCore's
// addSpell machinery auto-learns alongside the spell we asked for.
@@ -340,14 +623,15 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& out)
// Death Grip, Blood Plague, Blood Presence, Forceful Deflection, Runic
// Focus). We diff the player's spell list before/after `learnSpell` to
// classify those side effects:
// * Passives (Frost Fever, Blood Presence, Forceful Deflection, Runic
// Focus, Blood Plague, ...) are kept since they're typically required
// for the parent ability to function. We record them in
// character_paragon_panel_spell_children so reset unlearns them
// alongside the parent.
// * Active dependents (Death Coil, Death Grip, ...) are revoked
// immediately via `removeSpell` so the player only ends up with what
// they actually purchased.
// * Passives that are pure spell-effect side effects (disease auras,
// etc.) are kept; we record them in character_paragon_panel_spell_children
// so reset unlearns them alongside the parent.
// * Passives that are skill-line cascade rewards on the same SkillLine
// as the rank being learned (Forceful Deflection with Blood Strike)
// are revoked like actives — they are not panel children.
// * Active dependents (Death Coil, Death Grip, Blood Presence, ...) are
// revoked immediately via `removeSpell` so the player only ends up
// with what they actually purchased.
// The "you have learned X" / "you have unlearned X" chat toasts that
// fire during this dance are silenced client-side via a SILENCE
// addon-channel window opened around the whole commit (see
@@ -365,6 +649,14 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
std::unordered_set<uint32> chainIds;
CollectSpellChainIds(trackId, chainIds);
bool const diag = sConfigMgr->GetOption<bool>("Paragon.Diag.PanelLearn", false);
if (diag)
{
LOG_INFO("module",
"[paragon-diag] PanelLearnSpellChain start: player={} lvl={} class={} baseSpellId={} trackId={} chainSize={}",
pl->GetName(), playerLevel, uint32(pl->getClass()), baseSpellId, trackId, chainIds.size());
}
uint32 cur = trackId;
while (cur)
{
@@ -381,27 +673,73 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
if (!pl->HasSpell(cur))
{
std::unordered_set<uint32> before = SnapshotKnownSpells(pl);
if (diag)
LOG_INFO("module",
"[paragon-diag] pre-learn rank={} spellMapSize={}",
cur, before.size());
pl->learnSpell(cur, false);
std::unordered_set<uint32> after = SnapshotKnownSpells(pl);
if (diag)
LOG_INFO("module",
"[paragon-diag] post-learn rank={} spellMapSize={} delta={}",
cur, after.size(),
int32(after.size()) - int32(before.size()));
// Diff: classify each new spell that wasn't in the chain we
// asked for. Passives stick (recorded as children); actives
// get revoked.
// asked for. Pure spell-effect passives stick (children); skill-
// line cascade passives and actives get revoked.
for (uint32 spellId : after)
{
if (before.count(spellId))
continue; // already known
if (chainIds.count(spellId))
continue; // a rank we asked for
{
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} (chain rank, kept)", spellId);
continue;
}
SpellInfo const* dep = sSpellMgr->GetSpellInfo(spellId);
if (!dep)
continue;
if (dep->IsPassive())
DbInsertPanelSpellChild(lowGuid, trackId, spellId);
{
if (IsSpellSkillLineCascadeDependent(cur, spellId))
{
pl->removeSpell(spellId, SPEC_MASK_ALL, false);
DbInsertPanelSpellRevoked(lowGuid, trackId, spellId);
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} (skill-line passive dep, REVOKED, parent={})",
spellId, trackId);
}
else
{
DbInsertPanelSpellChild(lowGuid, trackId, spellId);
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} (passive dep, kept as child of {})",
spellId, trackId);
}
}
else
{
pl->removeSpell(spellId, SPEC_MASK_ALL, false);
// Persist so we can re-revoke after the next login --
// AC's _LoadSkills -> learnSkillRewardedSpells will
// re-grant skill-rewarded actives (Blood Presence,
// Death Coil, Death Grip, ...) every time the player
// logs in.
DbInsertPanelSpellRevoked(lowGuid, trackId, spellId);
if (diag)
LOG_INFO("module",
"[paragon-diag] +{} (active dep, REVOKED, parent={})",
spellId, trackId);
}
}
}
@@ -412,6 +750,10 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
}
DbInsertPanelSpell(lowGuid, trackId);
if (diag)
LOG_INFO("module",
"[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId);
}
void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank)
@@ -749,6 +1091,12 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
DbUpsertPanelTalent(lowGuid, tid, r);
}
// One scoped sweep after all panel DB rows for this commit exist. Catches
// cascade dependents the per-rank diff missed when anchor SkillLine rows
// used a non-LEARN_* AcquireMethod (fixed in SkillLinesLinkedToSpell, but
// this is cheap insurance for mixed commits / talent side effects).
RevokeUnwantedCascadeSpellsForPlayer(pl);
SaveCurrencyToDb(pl);
SendSilenceClose(pl);
return true;
@@ -916,8 +1264,25 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err)
} while (r->NextRow());
}
// Best-effort revoke pass for active dependents the cascade may have
// re-installed since last commit (Blood Presence, Death Coil, ...).
// Without this, a player who upgraded to this build with orphan
// dependents already in their spellbook would still see them after
// Reset Abilities; with this they're swept the moment they reset.
if (QueryResult rev = CharacterDatabase.Query(
"SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid))
{
do
{
uint32 const sid = rev->Fetch()[0].Get<uint32>();
if (pl->HasSpell(sid))
pl->removeSpell(sid, SPEC_MASK_ALL, false);
} while (rev->NextRow());
}
CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spells WHERE guid = {}", lowGuid);
CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid);
CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid);
if (refundAE)
{
@@ -1040,6 +1405,48 @@ public:
LoadCurrencyFromDb(player);
PushCurrency(player);
PushSnapshot(player);
// AC's character load sequence runs _LoadSkills (which fires
// learnSkillRewardedSpells) and _LoadSpells before this hook,
// so any active dependents we revoked at panel-commit time
// (Blood Presence / Death Coil / Death Grip / ...) have been
// silently re-granted by the skill cascade. Walk our persisted
// revoke list and remove them again.
//
// `removeSpell` triggers SMSG_REMOVED_SPELL on the client which
// generates "You have unlearned X" CHAT_MSG_SYSTEM toasts. The
// chat frame buffers system messages while the loading screen
// is up and only flushes them after PLAYER_ENTERING_WORLD, so
// a paired OPEN/CLOSE we send here would already be CLOSED by
// the time those buffered toasts reach the filter. We open the
// silence window from the server side as a defensive measure,
// but rely on the client to keep the window open across the
// login flush via its own PA.Silence:Open({}) call in the
// PLAYER_LOGIN handler. The window auto-closes via the addon's
// fail-open timer (PA.Silence.WINDOW_SECONDS).
if (player && player->getClass() == CLASS_PARAGON
&& sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
{
std::vector<std::pair<uint32, uint32>> emptySpells;
std::vector<std::pair<uint32, uint32>> emptyTalents;
SendSilenceOpenForCommit(player, emptySpells, emptyTalents);
// Step 1: re-revoke spells we explicitly recorded last commit
// (cheap, exact: just walks the persisted revoke table).
RevokeBlockedSpellsForPlayer(player);
// Step 2: scoped sweep across SkillLines we activated via panel
// purchases. Catches cascade-granted active spells that the
// commit-time diff missed -- e.g., legacy characters whose
// first commit predates the per-purchase revoke list, or any
// cascade re-fire on relog. Only walks our own SkillLines, so
// racials / weapon skills / Defense rewards are never touched.
RevokeUnwantedCascadeSpellsForPlayer(player);
// Intentionally NOT calling SendSilenceClose here -- the chat
// frame buffers system messages during the loading screen and
// would flush them after the CLOSE arrives. The addon's
// fail-open timer (8s) closes the window after the flush.
}
}
void OnPlayerLogout(Player* player) override
@@ -1149,6 +1556,21 @@ public:
PushSnapshot(player);
return;
}
// Combat lockdown: any mutating control message ("C ...") is
// rejected while the player is in combat. The client also
// self-gates the buttons that would emit these (see
// ParagonAdvancement.lua), but the authoritative check lives
// here so a hand-crafted addon message can't bypass it.
// Read-only "Q ..." queries above are allowed in combat so
// the panel can keep its currency / snapshot displays current.
if (!body.empty() && body[0] == 'C' && player->IsInCombat())
{
SendAddonMessage(player,
"R ERR cannot use Character Advancement while in combat");
return;
}
if (body.compare(0, 9, "C COMMIT ") == 0)
{
std::string err;
@@ -1250,6 +1672,7 @@ public:
{ "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
{ "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
{ "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
{ "hat", HandleHat, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
};
static ChatCommandTable commandTable =
@@ -1365,6 +1788,152 @@ public:
handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl));
return true;
}
// .paragon hat — diagnostic for Honor Among Thieves on a Paragon.
// Prints HasTalent, HasAura, HasSpell, EquippedItemClass, ProcEvent
// hookup, and re-applies the talent's passive aura if missing.
//
// HAT in 3.3.5: rank ids 51698 (R1) / 51700 (R2) / 51701 (R3).
// Effect 0 is APPLY_AREA_AURA_PARTY -> SPELL_AURA_PROC_TRIGGER_SPELL
// (52916) which targets the rogue (the original caster) when ANY
// party member crits, and 52916 in turn casts 51699 to grant +1 CP.
static bool HandleHat(ChatHandler* handler)
{
Player* pl = handler->GetPlayer();
if (!pl)
return false;
constexpr std::array<uint32, 3> kHatRanks = { 51698u, 51700u, 51701u };
constexpr uint32 kHatProc = 52916u;
constexpr uint32 kHatTrigger = 51699u;
handler->PSendSysMessage("|cff00ffff[paragon hat]|r class={} lvl={} group={}",
uint32(pl->getClass()), pl->GetLevel(),
pl->GetGroup() ? "yes" : "no");
uint32 ownedRank = 0;
uint32 ownedRankId = 0;
for (uint32 i = 0; i < kHatRanks.size(); ++i)
{
uint32 const id = kHatRanks[i];
bool hasTalent = pl->HasTalent(id, pl->GetActiveSpec());
bool hasAura = pl->HasAura(id);
SpellInfo const* info = sSpellMgr->GetSpellInfo(id);
handler->PSendSysMessage(
" R{} ({}): HasTalent={} HasAura={} HasSpell={} info={}",
i + 1, id,
hasTalent ? "Y" : "n",
hasAura ? "Y" : "n",
pl->HasSpell(id) ? "Y" : "n",
info ? "ok" : "MISSING");
if (hasTalent && !ownedRank)
{
ownedRank = i + 1;
ownedRankId = id;
}
}
SpellInfo const* procInfo = sSpellMgr->GetSpellInfo(kHatProc);
SpellInfo const* trigInfo = sSpellMgr->GetSpellInfo(kHatTrigger);
handler->PSendSysMessage(" proc52916 info={} trig51699 info={}",
procInfo ? "ok" : "MISSING",
trigInfo ? "ok" : "MISSING");
if (!ownedRank)
{
handler->PSendSysMessage("|cffff8000No HAT rank in active spec.|r");
return true;
}
SpellInfo const* rank = sSpellMgr->GetSpellInfo(ownedRankId);
if (!rank)
return true;
// Inspect the talent rank effects so we can see exactly what AC
// thinks should be applied.
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
SpellEffectInfo const& eff = rank->Effects[i];
if (eff.Effect == 0)
continue;
handler->PSendSysMessage(
" eff[{}] effect={} aura={} targetA={} trigger={} basePts={}",
i, uint32(eff.Effect), uint32(eff.ApplyAuraName),
uint32(eff.TargetA.GetTarget()), eff.TriggerSpell, eff.BasePoints);
}
// Force-apply the rank passive aura if it isn't already there. We
// use Aura::TryRefreshStackOrCreate so we don't go through CheckCast
// (which can fail silently for various reasons).
if (!pl->HasAura(ownedRankId))
{
Aura* a = Aura::TryRefreshStackOrCreate(rank, MAX_EFFECT_MASK, pl, pl);
handler->PSendSysMessage(" force-apply rank aura -> {}",
a ? "OK" : "FAILED");
}
else
{
handler->PSendSysMessage(" rank aura already on player.");
}
// Dump the live SpellProcEntry the engine will use when checking
// "should this aura proc". Confirms the ProcFlags fallback from
// SpellInfo, and the SpellPhase / Hit gates.
if (SpellProcEntry const* pe = sSpellMgr->GetSpellProcEntry(ownedRankId))
{
handler->PSendSysMessage(
" ProcEntry({}): ProcFlags=0x{:X} SpellTypeMask=0x{:X} SpellPhaseMask=0x{:X} HitMask=0x{:X} chance={} cd={}ms",
ownedRankId,
pe->ProcFlags, pe->SpellTypeMask, pe->SpellPhaseMask,
pe->HitMask, pe->Chance,
uint32(pe->Cooldown.count()));
}
else
{
handler->PSendSysMessage(
" ProcEntry({}): NONE (proc system will not fire this aura)",
ownedRankId);
}
// Walk the player's applied auras and report whether the engine
// currently sees an effect-mask flag turned on for the rank's
// PROC_TRIGGER_SPELL effect. If the aura is up but the flag is
// off, the engine won't proc it.
if (AuraApplication* aa = pl->GetAuraApplication(ownedRankId))
{
handler->PSendSysMessage(
" AuraApp: effectMask=0x{:X} effectsToApply=0x{:X} flags=0x{:X}",
uint32(aa->GetEffectMask()),
uint32(aa->GetEffectsToApply()),
uint32(aa->GetFlags()));
}
else
{
handler->PSendSysMessage(" AuraApp: NONE on player");
}
// Force-fire the trigger chain (52916 -> dummy -> 51699 -> +CP) on
// the player's current target. If this works, the proc machinery
// is at fault. If THIS fails, the trigger spells are at fault.
if (Unit* tgt = pl->GetSelectedUnit())
{
uint32 const cpBefore = pl->GetComboPoints(tgt);
// Mimic spell_rog_honor_among_thieves::HandleProc: cast 52916
// FROM tgt -> tgt with the rogue/Paragon as original caster.
tgt->CastSpell(tgt, kHatProc, true, nullptr, nullptr, pl->GetGUID());
uint32 const cpAfter = pl->GetComboPoints(tgt);
handler->PSendSysMessage(
" force-fire 52916 on {}: CP {} -> {}",
tgt->GetName(), cpBefore, cpAfter);
}
else
{
handler->PSendSysMessage(
" no target selected: pick a unit and run again to force-fire 52916.");
}
return true;
}
};
} // namespace (anonymous)
+51 -4
View File
@@ -5,15 +5,20 @@
* so Paragon can reuse other classes' mechanics in narrowly scoped contexts.
*/
#include "Chat.h"
#include "Config.h"
#include "GameTime.h"
#include "Log.h"
#include "ObjectGuid.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "UnitDefines.h"
#include "Config.h"
#include "Log.h"
#include "GameTime.h"
#include "ObjectGuid.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <fmt/format.h>
#include <string>
#include <unordered_map>
class Paragon_PlayerScript : public PlayerScript
@@ -184,6 +189,45 @@ public:
{
player->ResyncRunes(MAX_RUNES);
st.lastReadyMask = readyMask;
// Authoritative rune CD pump (PARAA "R RUNES cd0 cd1 ... cd5",
// ms remaining per slot, 0 = ready). The 3.3.5 client engine
// class-gates SMSG_RESYNC_RUNES / SMSG_SPELL_GO RUNE_LIST to DK,
// so the Paragon RuneFrame sim drives the visual entirely off
// COMBAT_LOG_EVENT_UNFILTERED:SPELL_CAST_SUCCESS. The combat log
// arrives ~100200ms after the server already started the
// cooldown, so the client's local timer trails the server. When
// the user spams a rune spell, the server's slot refreshes
// first, accepts the next cast, but the client UI still shows
// CD remaining → "leak-through" past a greyed icon. Pushing the
// actual remaining ms on every mask transition keeps the
// visual locked to server state.
std::string body = "R RUNES";
for (uint8 i = 0; i < MAX_RUNES; ++i)
body += " " + std::to_string(player->GetRuneCooldown(i));
std::string const payload = std::string(kParagonAddonPrefix) + "\t" + body;
WorldPacket runePkt;
ChatHandler::BuildChatPacket(runePkt, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload);
player->SendDirectMessage(&runePkt);
}
// Combo point pump: the 3.3.5 client engine class-gates SMSG_UPDATE_COMBO_POINTS
// to rogue / druid, so the Paragon UI sim never sees CP changes from
// Honor Among Thieves / Mutilate / etc. via either the engine state or
// the client-side combat-log inference (HAT's 51699 trigger fires with a
// null target and doesn't always emit SPELL_CAST_SUCCESS in the log).
// Push the count over PARAA whenever it changes; the addon's combo
// simulator listens for "R CP <n>" and overwrites paragonCP, so the
// ComboFrame on the target frame paints reliably.
int8 const cp = player->GetComboPoints();
if (cp != st.lastCp)
{
std::string const payload = std::string(kParagonAddonPrefix) + "\t"
+ fmt::format("R CP {}", int32(cp));
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload);
player->SendDirectMessage(&data);
st.lastCp = cp;
}
if (!sConfigMgr->GetOption<bool>("Paragon.Diag.RuneTrace", false))
@@ -214,8 +258,11 @@ private:
struct ParagonRuneSyncState
{
uint8 lastReadyMask{0xFFu}; // sentinel: no prior snapshot
int8 lastCp{-1}; // sentinel: no prior snapshot
};
static constexpr char const* kParagonAddonPrefix = "PARAA";
static std::unordered_map<ObjectGuid, ParagonRuneSyncState> runeSyncByGuid;
};