From 8bf82e4efb65b5adf6f7c5dd30e4316450137300 Mon Sep 17 00:00:00 2001 From: Sven Bledt Date: Sun, 2 Aug 2026 00:47:12 +0200 Subject: [PATCH 1/2] core/player: re-evaluate corruption penalties without re-applying them Player::UpdateCorruption had three problems, all of which left the CorruptionEffects.db2 threshold penalties out of step with the player. It re-applied live auras. The loop ended in an unconditional CastSpell(this, aura, true), so every corruption rating change re-cast penalties that were already present, restarting their durations and resetting their proc state. It now casts only what is missing and lets an aura that is already applied recalculate its own amounts in place. It was never re-evaluated on an area change. Each CorruptionEffects row carries a PlayerConditionID, and the sync ran only from UpdateRating(CR_CORRUPTION | CR_CORRUPTION_RESISTANCE) - a gear or rating-aura change. A player who walked into an area where the condition stopped matching kept the penalty indefinitely, and one who walked out never regained it. UpdateArea now schedules a sync. It was never re-evaluated on resurrect. RemoveAllAurasOnDeath strips the penalties and neither a rating change nor an area transition follows, so reviving where you fell left the player uncorrupted until they happened to cross an area border. That is why a corpse run appeared to fix it and .revive did not. Syncing at each mutation site is wrong in the other direction: a bulk item rebuild strips every item's corruption before re-applying it, so a per-mutation sync walks the total down to zero and tears down each tier's aura on the way. The sync is now coalesced behind a dirty flag and runs once per Player::Update tick. The two item-level rebuilds that did not bracket themselves with SetCanModifyStats now do, and UpdateItemLevelAreaBasedScaling settles its derived stats afterwards - without that the max health it scales the player to was one rebuild stale. GetEffectiveCorruption is split out of UpdateCorruption so callers that need the value do not repeat the rating subtraction. Verified in-game: penalties apply and remove as corruption crosses each threshold, survive a gear swap, and return on resurrect. --- src/server/game/Entities/Player/Player.cpp | 31 +++++++++++++++++++- src/server/game/Entities/Player/Player.h | 6 ++++ src/server/game/Entities/Unit/StatSystem.cpp | 15 ++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 78ac3a3a..90cb4454 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -1088,6 +1088,12 @@ void Player::Update(uint32 p_time) if (!IsInWorld()) return; + if (m_corruptionNeedsUpdate) + { + m_corruptionNeedsUpdate = false; + UpdateCorruption(); + } + // undelivered mail if (m_nextMailDelivereTime && m_nextMailDelivereTime <= time(nullptr)) { @@ -4635,6 +4641,11 @@ void Player::ResurrectPlayer(float restore_percent, bool applySickness) setDeathState(ALIVE); + // RemoveAllAurasOnDeath stripped the corruption penalties and nothing puts them back: + // the sync is driven by a rating change or an area transition, and resurrecting is + // neither. Reviving where you fell otherwise leaves the player uncorrupted. + ScheduleCorruptionUpdate(); + // add the flag to make sure opcode is always sent AddUnitMovementFlag(MOVEMENTFLAG_WATERWALKING); SetWaterWalking(false); @@ -5526,7 +5537,11 @@ void Player::UpdateRating(CombatRating cr) break; case CR_CORRUPTION: case CR_CORRUPTION_RESISTANCE: - UpdateCorruption(); + // Bulk rebuilds strip every item's corruption before re-applying it, so syncing per + // mutation would walk the total down to zero and tear down each tier's aura on the + // way. Callers that clear this flag are responsible for one sync when they finish. + if (affectStats) + ScheduleCorruptionUpdate(); break; case CR_SPEED: case CR_RESILIENCE_PLAYER_DAMAGE: @@ -7689,6 +7704,11 @@ void Player::UpdateArea(uint32 newArea) if (garrison.second->IsAllowedArea(newArea)) garrison.second->Enter(); } + + // A corruption penalty can be gated on a PlayerConditionID that reads the player's + // location, and nothing else re-evaluates those conditions when only the area + // changes. Safe on every transition: the sync casts only what is missing. + ScheduleCorruptionUpdate(); } } @@ -30832,12 +30852,15 @@ void Player::CreateChallengeKey(Item* item) void Player::SetEffectiveLevelAndMaxItemLevel(uint32 effectiveLevel, uint32 maxItemLevel) { float healthPct = GetHealthPct(); + SetCanModifyStats(false); _RemoveAllItemMods(); SetUpdateFieldValue(m_values.ModifyValue(&Unit::m_unitData).ModifyValue(&UF::UnitData::EffectiveLevel), effectiveLevel); SetUpdateFieldValue(m_values.ModifyValue(&Unit::m_unitData).ModifyValue(&UF::UnitData::MaxItemLevel), maxItemLevel); _ApplyAllItemMods(); + SetCanModifyStats(true); + UpdateAverageItemLevel(); uint32 basemana = 0; @@ -30865,9 +30888,15 @@ void Player::UpdateItemLevelAreaBasedScaling() if (_usePvpItemLevels != pvpActivity) { float healthPct = GetHealthPct(); + SetCanModifyStats(false); _RemoveAllItemMods(); ActivatePvpItemLevels(pvpActivity); _ApplyAllItemMods(); + SetCanModifyStats(true); + // The bracket defers every derived stat, not only corruption, and neither item-mod + // pass recomputes on its own - so settle them all once here, as _ApplyAllStatBonuses + // does, leaving the max health scaled below freshly computed rather than stale. + UpdateAllStats(); SetHealth(CalculatePct(GetMaxHealth(), healthPct)); } // @todo other types of power scaling diff --git a/src/server/game/Entities/Player/Player.h b/src/server/game/Entities/Player/Player.h index 1c19ed79..537d2bb8 100644 --- a/src/server/game/Entities/Player/Player.h +++ b/src/server/game/Entities/Player/Player.h @@ -1993,7 +1993,12 @@ class TC_GAME_API Player : public Unit, public GridObject void UpdateLeechPercentage(); void UpdateSpellCritChance(); + float GetEffectiveCorruption() const; void UpdateCorruption(); + // Corruption penalties are synced once per tick rather than at each mutation site. + // A single item swap can move several corrupted pieces through several intermediate + // states; syncing each one would apply and remove the same penalty auras repeatedly. + void ScheduleCorruptionUpdate() { m_corruptionNeedsUpdate = true; } void UpdateArmorPenetration(int32 amount); void UpdateExpertise(WeaponAttackType attType); void ApplyManaRegenBonus(int32 amount, bool apply); @@ -3016,6 +3021,7 @@ class TC_GAME_API Player : public Unit, public GridObject bool m_canParry; bool m_canBlock; bool m_canTitanGrip; + bool m_corruptionNeedsUpdate = false; uint32 m_titanGripPenaltySpellId; uint8 m_swingErrorMsg; diff --git a/src/server/game/Entities/Unit/StatSystem.cpp b/src/server/game/Entities/Unit/StatSystem.cpp index c8d70d0e..14031668 100644 --- a/src/server/game/Entities/Unit/StatSystem.cpp +++ b/src/server/game/Entities/Unit/StatSystem.cpp @@ -732,9 +732,14 @@ void Player::UpdateSpellCritChance() SetUpdateFieldValue(m_values.ModifyValue(&Player::m_activePlayerData).ModifyValue(&UF::ActivePlayerData::SpellCritPercentage), crit); } +float Player::GetEffectiveCorruption() const +{ + return GetRatingBonusValue(CR_CORRUPTION) - GetRatingBonusValue(CR_CORRUPTION_RESISTANCE); +} + void Player::UpdateCorruption() { - float effectiveCorruption = GetRatingBonusValue(CR_CORRUPTION) - GetRatingBonusValue(CR_CORRUPTION_RESISTANCE); + float const effectiveCorruption = GetEffectiveCorruption(); for (CorruptionEffectsEntry const* corruptionEffect : sCorruptionEffectsStore) { if ((CorruptionEffectsFlag(corruptionEffect->Flags) & CorruptionEffectsFlag::Disabled) != CorruptionEffectsFlag::None) @@ -755,7 +760,13 @@ void Player::UpdateCorruption() } } - CastSpell(this, corruptionEffect->Aura, true); + // Re-casting a penalty that is already applied restarts its duration and resets its + // proc state, and this runs on every rating change and area transition. Cast only + // what is missing; what is already there recomputes its magnitudes in place. + if (Aura* corruptionAura = GetAura(corruptionEffect->Aura)) + corruptionAura->RecalculateAmountOfEffects(); + else + CastSpell(this, corruptionEffect->Aura, true); } } From 2fa19cb8ddc53ad783d0aa94699d0eb0e45edf66 Mon Sep 17 00:00:00 2001 From: Sven Bledt Date: Sun, 2 Aug 2026 00:47:44 +0200 Subject: [PATCH 2/2] core/commands: add .debug corruption Corruption penalties are driven entirely by CorruptionEffects.db2, and when one fails to apply there is no way to see which of the three gates rejected it - the threshold, the PlayerConditionID, or the row being flagged disabled. The only alternative is equipping corrupted gear and inferring the answer from what happens. .debug corruption prints the selected player's effective corruption alongside the two ratings it derives from, then every CorruptionEffects row with its threshold, aura, condition, the verdict on that player and whether the aura is actually applied. A server whose client data has no CorruptionEffects rows at all reports that explicitly, since it is otherwise indistinguishable from a core fault. RBAC-gated behind RBAC_PERM_COMMAND_DEBUG like the rest of the table. --- src/server/scripts/Commands/cs_debug.cpp | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/server/scripts/Commands/cs_debug.cpp b/src/server/scripts/Commands/cs_debug.cpp index db540dba..6d1814da 100644 --- a/src/server/scripts/Commands/cs_debug.cpp +++ b/src/server/scripts/Commands/cs_debug.cpp @@ -117,6 +117,7 @@ class debug_commandscript : public CommandScript { "transportState", rbac::RBAC_PERM_COMMAND_DEBUG, false, &HandleDebugTransportStateCommand, "" }, { "worldstate" , rbac::RBAC_PERM_COMMAND_DEBUG, false, &HandleDebugWorldStateCommand, "" }, { "wsexpression" , rbac::RBAC_PERM_COMMAND_DEBUG, false, &HandleDebugWSExpressionCommand, "" }, + { "corruption", rbac::RBAC_PERM_COMMAND_DEBUG, false, &HandleDebugCorruptionCommand, "" }, }; static std::vector commandTable = { @@ -1429,6 +1430,48 @@ class debug_commandscript : public CommandScript return true; } + static bool HandleDebugCorruptionCommand(ChatHandler* handler, char const* /*args*/) + { + Player* target = handler->getSelectedPlayerOrSelf(); + if (!target) + { + handler->SendSysMessage(LANG_NO_CHAR_SELECTED); + handler->SetSentErrorMessage(true); + return false; + } + + float const effectiveCorruption = target->GetEffectiveCorruption(); + handler->PSendSysMessage("Corruption for %s: effective %.2f (corruption %.2f - resistance %.2f)", + target->GetName().c_str(), effectiveCorruption, + target->GetRatingBonusValue(CR_CORRUPTION), target->GetRatingBonusValue(CR_CORRUPTION_RESISTANCE)); + + uint32 rows = 0; + for (CorruptionEffectsEntry const* corruptionEffect : sCorruptionEffectsStore) + { + ++rows; + + char const* state; + if ((CorruptionEffectsFlag(corruptionEffect->Flags) & CorruptionEffectsFlag::Disabled) != CorruptionEffectsFlag::None) + state = "disabled"; + else if (effectiveCorruption < corruptionEffect->MinCorruption) + state = "below threshold"; + else if (PlayerConditionEntry const* playerCondition = sPlayerConditionStore.LookupEntry(corruptionEffect->PlayerConditionID)) + state = ConditionMgr::IsPlayerMeetingCondition(target, playerCondition) ? "qualifies" : "condition failed"; + else + state = "qualifies"; + + handler->PSendSysMessage(" row %u: min %.2f aura %d cond %d - %s, aura %s", + corruptionEffect->ID, corruptionEffect->MinCorruption, corruptionEffect->Aura, + corruptionEffect->PlayerConditionID, state, + target->HasAura(corruptionEffect->Aura) ? "APPLIED" : "absent"); + } + + if (!rows) + handler->SendSysMessage("CorruptionEffects.db2 holds no rows - no penalty can apply on this server. That is missing client data, not a core fault."); + + return true; + } + static bool HandleDebugMaxItemLevelCommand(ChatHandler* handler, char const* args) { CommandArgs commandArgs = CommandArgs(handler, args, { CommandArgs::ARG_UINT, CommandArgs::ARG_UINT });