From 1ea469f61e7d4e52b2ad471822d2fb88b1ea3500 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:30:44 +0100 Subject: [PATCH 1/4] Make area spells hit other players in PvP Mass spells only ran on the caster's client, and hazards like the fire from wall spells skipped the local player since it isn't hostile to a remote player. Send self-cast area attacks to the other clients, and let hazards and explosions from a remote player reach the local player while PvP is on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Actor.cpp | 48 +++++++++++++++++++ .../client/Games/Skyrim/Magic/MagicTarget.cpp | 26 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e86..c86c5e46f 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -129,6 +129,9 @@ TP_THIS_FUNCTION(TCharacterDestructor, Actor*, Actor); TP_THIS_FUNCTION(TAddInventoryItem, void, Actor, TESBoundObject* apItem, ExtraDataList* apExtraData, int32_t aCount, TESObjectREFR* apOldOwner); TP_THIS_FUNCTION(TPickUpObject, void*, Actor, TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2); TP_THIS_FUNCTION(TDropObject, void*, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation); +TP_THIS_FUNCTION(TIsHostileToActor, bool, Actor, Actor* apTarget); +TP_THIS_FUNCTION(TCheckValidTarget, bool, Actor, TESObjectREFR* apTarget); +using TApplyHazardToPlayer = uint64_t(void** apContext, void* apPlayerHandle); TP_THIS_FUNCTION(TSetPosition, char, Actor, NiPoint3& acPosition); TP_THIS_FUNCTION(TActorProcess, char, Actor, float aValue); @@ -143,6 +146,9 @@ static TRemoveSpell* RealRemoveSpell = nullptr; static TAddInventoryItem* RealAddInventoryItem = nullptr; static TPickUpObject* RealPickUpObject = nullptr; static TDropObject* RealDropObject = nullptr; +static TIsHostileToActor* RealIsHostileToActor = nullptr; +static TCheckValidTarget* RealCheckValidTarget = nullptr; +static TApplyHazardToPlayer* RealApplyHazardToPlayer = nullptr; static TSetPosition* RealSetPosition = nullptr; static TActorProcess* RealActorProcess = nullptr; @@ -1107,6 +1113,39 @@ void Actor::PickUpObject(TESObjectREFR* apObject, int32_t aCount, bool aUnk1, fl TiltedPhoques::ThisCall(RealPickUpObject, this, apObject, aCount, aUnk1, aUnk2); } +// A hazard (the fire on the ground from a wall spell, for example) only affects actors that are hostile to its owner, +// which the local player never is to a remote player since both are in the player faction. +static thread_local Actor* s_pRemoteHazardOwner = nullptr; + +bool TP_MAKE_THISCALL(HookIsHostileToActor, Actor, Actor* apTarget) +{ + if (s_pRemoteHazardOwner && s_pRemoteHazardOwner == apTarget && apThis->GetExtension()->IsLocalPlayer()) + return true; + + return TiltedPhoques::ThisCall(RealIsHostileToActor, apThis, apTarget); +} + +uint64_t HookApplyHazardToPlayer(void** apContext, void* apPlayerHandle) +{ + auto* pOwner = static_cast(apContext[0]); + if (pOwner && pOwner->GetExtension()->IsRemotePlayer() && World::Get().GetServerSettings().PvpEnabled) + s_pRemoteHazardOwner = pOwner; + + const uint64_t cResult = RealApplyHazardToPlayer(apContext, apPlayerHandle); + s_pRemoteHazardOwner = nullptr; + return cResult; +} + +// An explosion ignores whoever its owner isn't fighting while the owner is in combat, and a remote player's +// combat group doesn't have the local player in it. +bool TP_MAKE_THISCALL(HookCheckValidTarget, Actor, TESObjectREFR* apTarget) +{ + if (apTarget == PlayerCharacter::Get() && apThis->GetExtension()->IsRemotePlayer() && World::Get().GetServerSettings().PvpEnabled) + return true; + + return TiltedPhoques::ThisCall(RealCheckValidTarget, apThis, apTarget); +} + void* TP_MAKE_THISCALL(HookDropObject, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation) { auto& modSystem = World::Get().GetModSystem(); @@ -1303,6 +1342,9 @@ static TiltedPhoques::Initializer s_actorHooks( POINTER_SKYRIMSE(TAddInventoryItem, s_addInventoryItem, 37525); POINTER_SKYRIMSE(TPickUpObject, s_pickUpObject, 37521); POINTER_SKYRIMSE(TDropObject, s_dropObject, 40454); + POINTER_SKYRIMSE(TIsHostileToActor, s_isHostileToActor, 37537); + POINTER_SKYRIMSE(TCheckValidTarget, s_checkValidTarget, 38560); + POINTER_SKYRIMSE(TApplyHazardToPlayer, s_applyHazardToPlayer, 43987); POINTER_SKYRIMSE(TUpdateDetectionState, s_updateDetectionState, 42704); POINTER_SKYRIMSE(TProcessResponse, s_processResponse, 39643); POINTER_SKYRIMSE(TInitiateMountPackage, s_initiateMountPackage, 37905); @@ -1325,6 +1367,9 @@ static TiltedPhoques::Initializer s_actorHooks( RealAddInventoryItem = s_addInventoryItem.Get(); RealPickUpObject = s_pickUpObject.Get(); RealDropObject = s_dropObject.Get(); + RealIsHostileToActor = s_isHostileToActor.Get(); + RealCheckValidTarget = s_checkValidTarget.Get(); + RealApplyHazardToPlayer = s_applyHazardToPlayer.Get(); RealUpdateDetectionState = s_updateDetectionState.Get(); RealProcessResponse = s_processResponse.Get(); RealInitiateMountPackage = s_initiateMountPackage.Get(); @@ -1346,6 +1391,9 @@ static TiltedPhoques::Initializer s_actorHooks( TP_HOOK(&RealAddInventoryItem, HookAddInventoryItem); TP_HOOK(&RealPickUpObject, HookPickUpObject); TP_HOOK(&RealDropObject, HookDropObject); + TP_HOOK(&RealIsHostileToActor, HookIsHostileToActor); + TP_HOOK(&RealCheckValidTarget, HookCheckValidTarget); + TP_HOOK(&RealApplyHazardToPlayer, HookApplyHazardToPlayer); TP_HOOK(&RealUpdateDetectionState, HookUpdateDetectionState); TP_HOOK(&RealProcessResponse, HookProcessResponse); TP_HOOK(&RealInitiateMountPackage, HookInitiateMountPackage); diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b..1d1250e6e 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -81,6 +81,18 @@ bool MagicTarget::AddTargetData::IsForbiddenEffect(Actor* apTarget) return pEffectItem->IsNightVisionEffect(); } +// Hostile area spells that are cast on the caster (Mass Paralysis, for example) have no projectile, so nothing about +// them runs on any client but the caster's. Those are sent to the other clients once they find a player. +static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexcept +{ + if (acData.pSpell->iHostileCount <= 0 || acData.pEffectItem->data.iArea <= 0 || !World::Get().GetServerSettings().PvpEnabled) + return false; + + const SpellItem* pSpellItem = Cast(acData.pSpell); + return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONCENTRATION + && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; +} + Actor* MagicTarget::GetTargetAsActor() { TP_THIS_FUNCTION(TGetTargetAsActor, Actor*, MagicTarget); @@ -118,6 +130,12 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) + { + World::Get().GetRunner().Trigger(addTargetEvent); + return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -193,7 +211,13 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, void* arArgs, float afResistance) { if (s_autoSucceedEffectCheck) - return true; + { + // The victim's resistances and the effect's conditions still get to refuse an area attack from another player. + Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); + const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); + if (!cIsFromRemotePlayer || !IsSelfCastAreaAttack(*apThis)) + return true; + } return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); } From f0ec40dc62cd71157076059c69f516a69c171222 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:37:38 +0100 Subject: [PATCH 2/4] Make cloak spells hit other players in PvP A cloak's copy on the victim's client can't be relied on, since a concentration cloak only lasts while it's cast on the wearer's client. Let that client decide on the hits and send them to the victim, whose own checks can still refuse them, and ignore the hits from the copy's cloak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Events/AddTargetEvent.h | 2 ++ .../client/Games/Skyrim/Magic/MagicTarget.cpp | 25 +++++++++++++++++-- Code/client/Services/Generic/MagicService.cpp | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index fc7baf831..ff3d91582 100644 --- a/Code/client/Events/AddTargetEvent.h +++ b/Code/client/Events/AddTargetEvent.h @@ -15,4 +15,6 @@ struct AddTargetEvent bool IsDualCasting{false}; bool ApplyHealPerkBonus{false}; bool ApplyStaminaPerkBonus{false}; + // A cloak hit on another player. Unlike other concentration spells, it isn't replayed on the victim's client. + bool IsPvpCloakHit{false}; }; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 1d1250e6e..9c0cff380 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -93,6 +93,13 @@ static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexc && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } +// CloakEffect passes this callback along with each of the targets it finds around the wearer. +static bool IsCloakHit(const MagicTarget::AddTargetData& acData) noexcept +{ + POINTER_SKYRIMSE(uint8_t, s_cloakCallbackVtable, 205851); + return acData.pCallback && *reinterpret_cast(acData.pCallback) == s_cloakCallbackVtable.Get(); +} + Actor* MagicTarget::GetTargetAsActor() { TP_THIS_FUNCTION(TGetTargetAsActor, Actor*, MagicTarget); @@ -130,6 +137,15 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + // The wearer's client is the one that knows how long the cloak lasts, a concentration cloak only lives on as + // long as it's being cast there. So it decides on the hits, and the copy's cloak is ignored on the victim's client. + if (IsCloakHit(arData) && arData.pCaster->GetExtension()->IsLocalPlayer() && World::Get().GetServerSettings().PvpEnabled) + { + addTargetEvent.IsPvpCloakHit = true; + World::Get().GetRunner().Trigger(addTargetEvent); + return false; + } + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) { World::Get().GetRunner().Trigger(addTargetEvent); @@ -168,6 +184,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // Heal and buff spells are already synced by the caster. if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell()) return false; + + // Cloak hits are synced by the wearer. + if (IsCloakHit(arData)) + return false; } } @@ -212,10 +232,11 @@ bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, { if (s_autoSucceedEffectCheck) { - // The victim's resistances and the effect's conditions still get to refuse an area attack from another player. + // The victim's resistances and the effect's conditions still get to refuse an attack from another player. + // Heal and buff spells have already passed them on the caster's client. Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); - if (!cIsFromRemotePlayer || !IsSelfCastAreaAttack(*apThis)) + if (!cIsFromRemotePlayer || apThis->pSpell->IsHealingSpell() || apThis->pSpell->IsBuffSpell()) return true; } diff --git a/Code/client/Services/Generic/MagicService.cpp b/Code/client/Services/Generic/MagicService.cpp index cfbbe1042..1a3e3e8a2 100644 --- a/Code/client/Services/Generic/MagicService.cpp +++ b/Code/client/Services/Generic/MagicService.cpp @@ -305,7 +305,7 @@ void MagicService::OnAddTargetEvent(const AddTargetEvent& acEvent) noexcept // These effects are applied through spell cast sync if (SpellItem* pSpellItem = Cast(TESForm::GetById(acEvent.SpellID))) { - if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell()) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpCloakHit) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; } From 86d67c43b90180a6ed1d81f57611e4a2b7650c80 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:51:23 +0100 Subject: [PATCH 3/4] Let spell conditions accept attacks from other players in PvP Cloak damage only lands on targets hostile to the caster, and some area effects skip the player's base form outright, so hits sent to the victim were refused. Pass those two conditions for an attack from another player, as other conditions like resistances still apply, and send concentration area effects from the caster's client too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Events/AddTargetEvent.h | 4 +- .../client/Games/Skyrim/Magic/MagicTarget.cpp | 69 +++++++++++++++---- Code/client/Services/Generic/MagicService.cpp | 2 +- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index ff3d91582..e6df8f259 100644 --- a/Code/client/Events/AddTargetEvent.h +++ b/Code/client/Events/AddTargetEvent.h @@ -15,6 +15,6 @@ struct AddTargetEvent bool IsDualCasting{false}; bool ApplyHealPerkBonus{false}; bool ApplyStaminaPerkBonus{false}; - // A cloak hit on another player. Unlike other concentration spells, it isn't replayed on the victim's client. - bool IsPvpCloakHit{false}; + // A hit on another player that the victim's client can't work out by itself, even for a concentration spell. + bool IsPvpForwarded{false}; }; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 9c0cff380..4357bceaa 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -24,6 +24,7 @@ TP_THIS_FUNCTION(TFindTargets, bool, MagicCaster, float afEffectivenessMult, int TP_THIS_FUNCTION(TAdjustForPerks, void, ActiveEffect, Actor* apCaster, MagicTarget* apTarget); TP_THIS_FUNCTION(THasPerk, bool, Actor, TESForm* apPerk, void* apUnk1, double* afReturnValue); TP_THIS_FUNCTION(TGetPerkRank, uint8_t, Actor, TESForm* apPerk); +using TConditionFunction = bool(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult); static TAddTarget* RealAddTarget = nullptr; static TCheckAddEffectTargetData* RealCheckAddEffectTargetData = nullptr; @@ -31,10 +32,13 @@ static TFindTargets* RealFindTargets = nullptr; static TAdjustForPerks* RealAdjustForPerks = nullptr; static THasPerk* RealHasPerk = nullptr; static TGetPerkRank* RealGetPerkRank = nullptr; +static TConditionFunction* RealConditionIsHostileToActor = nullptr; +static TConditionFunction* RealConditionGetIsID = nullptr; static thread_local bool s_autoSucceedEffectCheck = false; static thread_local bool s_applyHealPerkBonus = false; static thread_local bool s_applyStaminaPerkBonus = false; +static thread_local bool s_isCheckingAttackFromPlayer = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -82,15 +86,15 @@ bool MagicTarget::AddTargetData::IsForbiddenEffect(Actor* apTarget) } // Hostile area spells that are cast on the caster (Mass Paralysis, for example) have no projectile, so nothing about -// them runs on any client but the caster's. Those are sent to the other clients once they find a player. +// them runs on any client but the caster's. Those are sent to the other clients once they find a player. A replayed +// concentration spell doesn't get to decide on the targets either, since it only finds them once. static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexcept { if (acData.pSpell->iHostileCount <= 0 || acData.pEffectItem->data.iArea <= 0 || !World::Get().GetServerSettings().PvpEnabled) return false; const SpellItem* pSpellItem = Cast(acData.pSpell); - return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONCENTRATION - && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; + return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } // CloakEffect passes this callback along with each of the targets it finds around the wearer. @@ -121,6 +125,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (arData.pEffectItem->IsVampireLordEffect()) pTargetActorEx->GraphDescriptorHash = AnimationGraphDescriptor_VampireLordBehavior::m_key; + // The caster's client sends these, see below. + if (pTargetActorEx->IsLocalPlayer() && arData.pCaster && arData.pCaster->GetExtension()->IsRemotePlayer() && IsSelfCastAreaAttack(arData)) + return false; + if (ScopedSpellCastOverride::IsOverriden()) return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); @@ -141,14 +149,20 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // long as it's being cast there. So it decides on the hits, and the copy's cloak is ignored on the victim's client. if (IsCloakHit(arData) && arData.pCaster->GetExtension()->IsLocalPlayer() && World::Get().GetServerSettings().PvpEnabled) { - addTargetEvent.IsPvpCloakHit = true; + addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); return false; } if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) { + addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); + + // A script (the lightning from Unbounded Storms, for example) only has to run on the victim's client. + if (arData.pEffectItem->pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kScript) + return false; + return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); } @@ -230,17 +244,38 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, void* arArgs, float afResistance) { - if (s_autoSucceedEffectCheck) - { - // The victim's resistances and the effect's conditions still get to refuse an attack from another player. - // Heal and buff spells have already passed them on the caster's client. - Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); - const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); - if (!cIsFromRemotePlayer || apThis->pSpell->IsHealingSpell() || apThis->pSpell->IsBuffSpell()) - return true; - } + // The victim's resistances and the effect's conditions still get to refuse an attack from another player. + // Heal and buff spells have already passed them on the caster's client. + Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); + const bool cIsAttackFromPlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer() + && !apThis->pSpell->IsHealingSpell() && !apThis->pSpell->IsBuffSpell() && World::Get().GetServerSettings().PvpEnabled; + + if (s_autoSucceedEffectCheck && !cIsAttackFromPlayer) + return true; + + s_isCheckingAttackFromPlayer = cIsAttackFromPlayer; + const bool cResult = TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); + s_isCheckingAttackFromPlayer = false; + return cResult; +} - return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); +// Effects keep players out of their reach with conditions, which have to let an attack from another player through: +// cloak damage asks if the target is hostile to the caster or to the player, some area effects ask if the target isn't +// the player. These only run when an effect is added, not while it's active. +static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) +{ + const bool cResult = RealConditionIsHostileToActor(apSubject, apParam1, apParam2, apResult); + if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + *apResult = 1.0; + return cResult; +} + +static bool HookConditionGetIsID(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) +{ + const bool cResult = RealConditionGetIsID(apSubject, apParam1, apParam2, apResult); + if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + *apResult = 0.0; + return cResult; } bool TP_MAKE_THISCALL(HookFindTargets, MagicCaster, float afEffectivenessMult, int32_t* aruiTargetCount, TESBoundObject* apSource, char abLoadCast, char abAdjust) @@ -288,6 +323,8 @@ static TiltedPhoques::Initializer s_magicTargetHooks([]() { POINTER_SKYRIMSE(TAdjustForPerks, adjustForPerks, 34053); POINTER_SKYRIMSE(THasPerk, hasPerk, 21622); POINTER_SKYRIMSE(TGetPerkRank, getPerkRank, 37698); + POINTER_SKYRIMSE(TConditionFunction, conditionIsHostileToActor, 21472); + POINTER_SKYRIMSE(TConditionFunction, conditionGetIsID, 21487); RealAddTarget = addTarget.Get(); RealCheckAddEffectTargetData = checkAddEffectTargetData.Get(); @@ -295,11 +332,15 @@ static TiltedPhoques::Initializer s_magicTargetHooks([]() { RealAdjustForPerks = adjustForPerks.Get(); RealHasPerk = hasPerk.Get(); RealGetPerkRank = getPerkRank.Get(); + RealConditionIsHostileToActor = conditionIsHostileToActor.Get(); + RealConditionGetIsID = conditionGetIsID.Get(); TP_HOOK(&RealAddTarget, HookAddTarget); TP_HOOK(&RealCheckAddEffectTargetData, HookCheckAddEffectTargetData); TP_HOOK(&RealFindTargets, HookFindTargets); TP_HOOK(&RealAdjustForPerks, HookAdjustForPerks); TP_HOOK(&RealHasPerk, HookHasPerk); + TP_HOOK(&RealConditionIsHostileToActor, HookConditionIsHostileToActor); + TP_HOOK(&RealConditionGetIsID, HookConditionGetIsID); //TP_HOOK(&RealGetPerkRank, HookGetPerkRank); }); diff --git a/Code/client/Services/Generic/MagicService.cpp b/Code/client/Services/Generic/MagicService.cpp index 1a3e3e8a2..fbafed581 100644 --- a/Code/client/Services/Generic/MagicService.cpp +++ b/Code/client/Services/Generic/MagicService.cpp @@ -305,7 +305,7 @@ void MagicService::OnAddTargetEvent(const AddTargetEvent& acEvent) noexcept // These effects are applied through spell cast sync if (SpellItem* pSpellItem = Cast(TESForm::GetById(acEvent.SpellID))) { - if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpCloakHit) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpForwarded) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; } From 3c923d1e886deeb1ff155073ea549da2a8a62f71 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 22:25:24 +0100 Subject: [PATCH 4/4] Keep Unbounded Freezing's knockdown off other players It's the same ice form paralysis that has been kept away from players, and its effect refuses them on purpose. Stop overriding that condition for paralysis and stop sending the channeled kind to the victim. The cloak's frost damage and Mass Paralysis, which has no such condition, still land. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- .../client/Games/Skyrim/Magic/MagicTarget.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 4357bceaa..aa3cb4abb 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -39,6 +39,7 @@ static thread_local bool s_autoSucceedEffectCheck = false; static thread_local bool s_applyHealPerkBonus = false; static thread_local bool s_applyStaminaPerkBonus = false; static thread_local bool s_isCheckingAttackFromPlayer = false; +static thread_local bool s_isCheckingParalysis = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -97,6 +98,16 @@ static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexc return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } +// The ice form effect of Unbounded Freezing. Like the shout it comes from, it isn't meant to reach players. +static bool IsChanneledParalysis(const MagicTarget::AddTargetData& acData) noexcept +{ + if (acData.pEffectItem->pEffectSetting->eArchetype != EffectArchetypes::ArchetypeID::kParalysis) + return false; + + const SpellItem* pSpellItem = Cast(acData.pSpell); + return pSpellItem && pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION; +} + // CloakEffect passes this callback along with each of the targets it finds around the wearer. static bool IsCloakHit(const MagicTarget::AddTargetData& acData) noexcept { @@ -154,7 +165,7 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar return false; } - if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData) && !IsChanneledParalysis(arData)) { addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); @@ -254,14 +265,16 @@ bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, return true; s_isCheckingAttackFromPlayer = cIsAttackFromPlayer; + s_isCheckingParalysis = apThis->pEffectItem->pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kParalysis; const bool cResult = TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); s_isCheckingAttackFromPlayer = false; + s_isCheckingParalysis = false; return cResult; } // Effects keep players out of their reach with conditions, which have to let an attack from another player through: // cloak damage asks if the target is hostile to the caster or to the player, some area effects ask if the target isn't -// the player. These only run when an effect is added, not while it's active. +// the player. These only run when an effect is added, not while it's active. Paralysis keeps its player check. static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) { const bool cResult = RealConditionIsHostileToActor(apSubject, apParam1, apParam2, apResult); @@ -273,7 +286,7 @@ static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apPara static bool HookConditionGetIsID(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) { const bool cResult = RealConditionGetIsID(apSubject, apParam1, apParam2, apResult); - if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + if (s_isCheckingAttackFromPlayer && !s_isCheckingParalysis && apSubject == PlayerCharacter::Get()) *apResult = 0.0; return cResult; }