Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Code/client/Events/AddTargetEvent.h
Original file line number Diff line number Diff line change
Expand Up @@ -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};
};
48 changes: 48 additions & 0 deletions Code/client/Games/Skyrim/Actor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;

Expand Down Expand Up @@ -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<Actor*>(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();
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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);
Expand Down
103 changes: 101 additions & 2 deletions Code/client/Games/Skyrim/Magic/MagicTarget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ 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;
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
{
Expand Down Expand Up @@ -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<SpellItem>(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<SpellItem>(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<void* const*>(acData.pCallback) == s_cloakCallbackVtable.Get();
}

Actor* MagicTarget::GetTargetAsActor()
{
TP_THIS_FUNCTION(TGetTargetAsActor, Actor*, MagicTarget);
Expand All @@ -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);

Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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<MagicTarget**>(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)
Expand Down Expand Up @@ -243,18 +336,24 @@ 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();
RealFindTargets = findTargets.Get();
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);
});
2 changes: 1 addition & 1 deletion Code/client/Services/Generic/MagicService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ void MagicService::OnAddTargetEvent(const AddTargetEvent& acEvent) noexcept
// These effects are applied through spell cast sync
if (SpellItem* pSpellItem = Cast<SpellItem>(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;
}
Expand Down