diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index fc7baf831..e6df8f259 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 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/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..aa3cb4abb 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,14 @@ 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; +static thread_local bool s_isCheckingParalysis = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -81,6 +86,35 @@ 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. 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::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 +{ + 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); @@ -102,6 +136,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); @@ -118,6 +156,27 @@ 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.IsPvpForwarded = true; + World::Get().GetRunner().Trigger(addTargetEvent); + return false; + } + + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData) && !IsChanneledParalysis(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); + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -150,6 +209,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; } } @@ -192,10 +255,40 @@ 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 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; - return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); + 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. Paralysis keeps its player check. +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 && !s_isCheckingParalysis && 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) @@ -243,6 +336,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(); @@ -250,11 +345,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 cfbbe1042..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()) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpForwarded) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; }