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
75 changes: 75 additions & 0 deletions Code/client/Games/Skyrim/Forms/BGSPerk.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#pragma once

#include <Forms/TESForm.h>
#include <Games/Primitives.h>

struct Actor;

struct BGSPerkEntry
{
enum Type : uint32_t
{
kQuest = 0,
kAbility = 1,
kEntryPoint = 2,
};

virtual bool CheckConditionFilters(uint32_t aArgCount, void* apArgs);
virtual uint32_t GetFunction();
virtual void* GetFunctionData();
virtual void sub_3();
virtual Type GetType();

uint8_t rank;
uint8_t priority;
};
static_assert(offsetof(BGSPerkEntry, rank) == 0x8);
static_assert(offsetof(BGSPerkEntry, priority) == 0x9);

struct BGSEntryPointPerkEntry : BGSPerkEntry
{
static constexpr uint32_t kEntryPointCount = 0x5C;

uint8_t entryPoint;
};
static_assert(offsetof(BGSEntryPointPerkEntry, entryPoint) == 0x10);

struct BGSPerk : TESForm
{
uint8_t pad20[0x60 - sizeof(TESForm)];
GameArray<BGSPerkEntry*> perkEntries;
};
static_assert(offsetof(BGSPerk, perkEntries) == 0x60);

// Element of TESNPC::perkRanks. PlayerCharacter stores pointers to these for the perks a player earned.
struct PerkRankData
{
BGSPerk* pPerk;
uint8_t rank;
};
static_assert(sizeof(PerkRankData) == 0x10);

// The engine's visitors for Actor::VisitPerks (vtable slot 0xFA) and Actor::ForEachPerkEntry (slot 0x100).
// Returning 1 means continue.
struct PerkRankVisitor
{
virtual uint32_t Visit(PerkRankData* apPerkRank) = 0;
};

struct PerkEntryVisitor
{
virtual uint32_t Visit(BGSPerkEntry* apEntry) = 0;
};

// The visitor that BGSEntryPoint::HandleEntryPoint passes to Actor::ForEachPerkEntry.
struct HandleEntryPointVisitor : PerkEntryVisitor
{
uint32_t function;
void** ppArguments; // The perk owner, followed by the entry point's filter arguments
void* pResults;
Actor* pPerkOwner;
uint8_t argumentCount;
uint8_t resultCount;
};
static_assert(offsetof(HandleEntryPointVisitor, ppArguments) == 0x10);
static_assert(offsetof(HandleEntryPointVisitor, argumentCount) == 0x28);
15 changes: 14 additions & 1 deletion Code/client/Games/Skyrim/Magic/MagicTarget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <Games/ActorExtension.h>
#include <Games/Overrides.h>
#include <PlayerCharacter.h>
#include <RemotePerks.h>

#include <Effects/ActiveEffect.h>
#include <Forms/SpellItem.h>
Expand Down Expand Up @@ -49,7 +50,18 @@ bool MagicTarget::AddTarget(AddTargetData& arData, bool aApplyHealPerkBonus, boo
s_applyHealPerkBonus = aApplyHealPerkBonus;
s_applyStaminaPerkBonus = aApplyStaminaPerkBonus;

bool result = TiltedPhoques::ThisCall(RealAddTarget, this, arData);
bool result = false;
if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell())
{
// The caster's perks are already part of what they send for these, see ApplyHealPerkBonus.
ScopedIgnoreRemotePerks ignoreRemotePerks;
result = TiltedPhoques::ThisCall(RealAddTarget, this, arData);
}
else
{
ScopedRemotePerks remotePerks;
result = TiltedPhoques::ThisCall(RealAddTarget, this, arData);
}

s_autoSucceedEffectCheck = false;
s_applyHealPerkBonus = false;
Expand Down Expand Up @@ -153,6 +165,7 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar
}
}

ScopedRemotePerks remotePerks;
bool result = TiltedPhoques::ThisCall(RealAddTarget, apThis, arData);
if (result && arData.ShouldSync())
World::Get().GetRunner().Trigger(addTargetEvent);
Expand Down
228 changes: 228 additions & 0 deletions Code/client/Games/Skyrim/RemotePerks.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#include <RemotePerks.h>

#include <Actor.h>
#include <PlayerCharacter.h>
#include <World.h>

#include <Games/ActorExtension.h>

#include <algorithm>
#include <shared_mutex>

namespace
{
TP_THIS_FUNCTION(TVisitPerks, void, Actor, PerkRankVisitor* apVisitor);
TP_THIS_FUNCTION(THasPerkEntries, bool, Actor, uint8_t aEntryPoint);
TP_THIS_FUNCTION(TForEachPerkEntry, void, Actor, uint8_t aEntryPoint, PerkEntryVisitor* apVisitor);

TVisitPerks* RealVisitPerks = nullptr;
THasPerkEntries* RealHasPerkEntries = nullptr;
TForEachPerkEntry* RealForEachPerkEntry = nullptr;

// Hits and spells can resolve on engine worker threads, so readers take a reference to an immutable list.
std::shared_mutex s_perksLock;
RemotePerks::PerkMap s_perks;

thread_local uint32_t s_scopeDepth = 0;
thread_local uint32_t s_ignoreDepth = 0;

RemotePerks::PerkDataPtr GetRemotePlayerPerks(Actor* apActor) noexcept
{
if (s_ignoreDepth > 0 || !apActor->GetExtension()->IsRemotePlayer())
return nullptr;

if (!World::Get().GetServerSettings().PvpEnabled)
return nullptr;

std::shared_lock lock(s_perksLock);

const auto it = s_perks.find(apActor->formID);
return it != s_perks.end() ? it->second : nullptr;
}

// Entry points on the attacker's side of a hit take the target as a filter argument. Everything else, such as the
// attacker's stamina costs or weapon speed, is simulated by the client that owns the attacker.
bool TargetsLocalPlayer(PerkEntryVisitor* apVisitor) noexcept
{
POINTER_SKYRIMSE(uint8_t, s_handleEntryPointVisitorVtable, 195172);

if (*reinterpret_cast<void**>(apVisitor) != s_handleEntryPointVisitorVtable.Get())
return false;

const auto* pVisitor = static_cast<const HandleEntryPointVisitor*>(apVisitor);
const void* pPlayer = PlayerCharacter::Get();

for (uint32_t i = 1; i < pVisitor->argumentCount; ++i)
{
if (pVisitor->ppArguments[i] == pPlayer)
return true;
}

return false;
}

void TP_MAKE_THISCALL(HookVisitPerks, Actor, PerkRankVisitor* apVisitor)
{
if (s_scopeDepth > 0)
{
if (const auto pData = GetRemotePlayerPerks(apThis))
{
for (const PerkRankData& cPerk : pData->Perks)
{
if (apVisitor->Visit(const_cast<PerkRankData*>(&cPerk)) != 1)
break;
}
return;
}
}

TiltedPhoques::ThisCall(RealVisitPerks, apThis, apVisitor);
}

bool TP_MAKE_THISCALL(HookHasPerkEntries, Actor, uint8_t aEntryPoint)
{
if (aEntryPoint < BGSEntryPointPerkEntry::kEntryPointCount)
{
if (const auto pData = GetRemotePlayerPerks(apThis))
return !pData->Entries[aEntryPoint].empty();
}

return TiltedPhoques::ThisCall(RealHasPerkEntries, apThis, aEntryPoint);
}

void TP_MAKE_THISCALL(HookForEachPerkEntry, Actor, uint8_t aEntryPoint, PerkEntryVisitor* apVisitor)
{
if (aEntryPoint < BGSEntryPointPerkEntry::kEntryPointCount)
{
if (const auto pData = GetRemotePlayerPerks(apThis))
{
if (TargetsLocalPlayer(apVisitor))
{
// Perk conditions can check the attacker's perks
ScopedRemotePerks remotePerks;

for (BGSPerkEntry* pEntry : pData->Entries[aEntryPoint])
{
if (apVisitor->Visit(pEntry) != 1)
break;
}
}
return;
}
}

TiltedPhoques::ThisCall(RealForEachPerkEntry, apThis, aEntryPoint, apVisitor);
}
} // namespace

ScopedRemotePerks::ScopedRemotePerks() noexcept
{
++s_scopeDepth;
}

ScopedRemotePerks::~ScopedRemotePerks() noexcept
{
--s_scopeDepth;
}

ScopedIgnoreRemotePerks::ScopedIgnoreRemotePerks() noexcept
{
++s_ignoreDepth;
}

ScopedIgnoreRemotePerks::~ScopedIgnoreRemotePerks() noexcept
{
--s_ignoreDepth;
}

namespace RemotePerks
{
PerkDataPtr BuildPerkData(const TiltedPhoques::Vector<PlayerPerk>& acPerks) noexcept
{
auto pData = std::make_shared<PerkData>();
auto& modSystem = World::Get().GetModSystem();

for (const PlayerPerk& cPerk : acPerks)
{
const uint32_t cPerkId = modSystem.GetGameId(cPerk.Id);
BGSPerk* pPerk = cPerkId ? Cast<BGSPerk>(TESForm::GetById(cPerkId)) : nullptr;
if (!pPerk)
{
spdlog::debug("{}: skipping unknown perk, GameId base: {:X}, mod: {:X}", __FUNCTION__, cPerk.Id.BaseId, cPerk.Id.ModId);
continue;
}

pData->Perks.push_back(PerkRankData{pPerk, cPerk.Rank});

for (BGSPerkEntry* pEntry : pPerk->perkEntries)
{
// BGSPerk::ApplyPerk only applies the entries of the rank the perk is at.
if (!pEntry || pEntry->rank + 1 != cPerk.Rank || pEntry->GetType() != BGSPerkEntry::kEntryPoint)
continue;

const uint8_t cEntryPoint = static_cast<BGSEntryPointPerkEntry*>(pEntry)->entryPoint;
if (cEntryPoint >= BGSEntryPointPerkEntry::kEntryPointCount)
continue;

// Same order as AIProcess::AddPerkEntry: highest priority first, then lowest rank.
auto& entries = pData->Entries[cEntryPoint];
const auto cPosition = std::upper_bound(entries.begin(), entries.end(), pEntry, [](const BGSPerkEntry* apLhs, const BGSPerkEntry* apRhs) {
if (apLhs->priority != apRhs->priority)
return apLhs->priority > apRhs->priority;
return apLhs->rank < apRhs->rank;
});
entries.insert(cPosition, pEntry);
}
}

return pData;
}

void SetPerks(PerkMap&& aPerks) noexcept
{
std::unique_lock lock(s_perksLock);
s_perks.swap(aPerks);
}

void CollectLocalPlayerPerks(TiltedPhoques::Vector<PlayerPerk>& aPerks) noexcept
{
struct Visitor final : PerkRankVisitor
{
explicit Visitor(TiltedPhoques::Vector<PlayerPerk>& aPerks) noexcept
: Perks(aPerks)
{
}

uint32_t Visit(PerkRankData* apPerkRank) override
{
PlayerPerk perk{};
if (apPerkRank && apPerkRank->pPerk && apPerkRank->rank > 0 && World::Get().GetModSystem().GetServerModId(apPerkRank->pPerk->formID, perk.Id))
{
perk.Rank = apPerkRank->rank;
Perks.push_back(perk);
}
return 1;
}

TiltedPhoques::Vector<PlayerPerk>& Perks;
} visitor(aPerks);

// PlayerCharacter::VisitPerks: the base form's perks, then the earned ones.
POINTER_SKYRIMSE(TVisitPerks, s_visitPlayerPerks, 40680);
TiltedPhoques::ThisCall(s_visitPlayerPerks.Get(), static_cast<Actor*>(PlayerCharacter::Get()), static_cast<PerkRankVisitor*>(&visitor));
}
} // namespace RemotePerks

static TiltedPhoques::Initializer s_remotePerkHooks([]() {
POINTER_SKYRIMSE(TVisitPerks, visitPerks, 37693);
POINTER_SKYRIMSE(THasPerkEntries, hasPerkEntries, 37701);
POINTER_SKYRIMSE(TForEachPerkEntry, forEachPerkEntry, 37702);

RealVisitPerks = visitPerks.Get();
RealHasPerkEntries = hasPerkEntries.Get();
RealForEachPerkEntry = forEachPerkEntry.Get();

TP_HOOK(&RealVisitPerks, HookVisitPerks);
TP_HOOK(&RealHasPerkEntries, HookHasPerkEntries);
TP_HOOK(&RealForEachPerkEntry, HookForEachPerkEntry);
});
52 changes: 52 additions & 0 deletions Code/client/Games/Skyrim/RemotePerks.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include <Forms/BGSPerk.h>
#include <Structs/PlayerPerk.h>

#include <memory>
#include <unordered_map>
#include <vector>

/**
* Remote player copies have none of the perks their player earned: Character::VisitPerks only reads the TESNPC, those
* perks are stored on PlayerCharacter. When a remote player hits or casts on the local player, this client runs the
* attacker side of the hit against that copy, so the attacker's perks have to come from here.
* The perks are never added to the copy, since AddPerk would also run the perk's abilities on it.
*/
namespace RemotePerks
{
struct PerkData
{
std::vector<PerkRankData> Perks;
// Sorted the same way as AIProcess's perk entry arrays
std::vector<BGSPerkEntry*> Entries[BGSEntryPointPerkEntry::kEntryPointCount];
};

using PerkDataPtr = std::shared_ptr<const PerkData>;
using PerkMap = std::unordered_map<uint32_t, PerkDataPtr>;

// Perks that aren't in the local player's load order are skipped.
PerkDataPtr BuildPerkData(const TiltedPhoques::Vector<PlayerPerk>& acPerks) noexcept;
// Replaces the remote players' perks, keyed by the form id of their copy.
void SetPerks(PerkMap&& aPerks) noexcept;
// The local player's perks, those on the base form included.
void CollectLocalPlayerPerks(TiltedPhoques::Vector<PlayerPerk>& aPerks) noexcept;
} // namespace RemotePerks

// While alive, HasPerk and GetPerkRank on remote players answer from their perk list on this thread.
struct ScopedRemotePerks
{
ScopedRemotePerks() noexcept;
~ScopedRemotePerks() noexcept;

TP_NOCOPYMOVE(ScopedRemotePerks);
};

// While alive, remote players' perk lists are ignored on this thread.
struct ScopedIgnoreRemotePerks
{
ScopedIgnoreRemotePerks() noexcept;
~ScopedIgnoreRemotePerks() noexcept;

TP_NOCOPYMOVE(ScopedIgnoreRemotePerks);
};
Loading