diff --git a/Code/client/Events/PlayerHitEvent.h b/Code/client/Events/PlayerHitEvent.h new file mode 100644 index 000000000..bf6773f79 --- /dev/null +++ b/Code/client/Events/PlayerHitEvent.h @@ -0,0 +1,15 @@ +#pragma once + +/** + * @brief Dispatched when the local player or an NPC owned here lands a weapon hit on a remote player. + * + * Damage is the hit's physical damage before the victim's armor and block, see Actor::ApplyHitFromOwner(). + */ +struct PlayerHitEvent +{ + uint32_t HitteeId; + uint32_t AttackerId; + uint32_t WeaponId; + float Damage; + uint32_t Flags; +}; diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 79410e748..6ade29aad 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -22,6 +22,9 @@ #include #include #include +#include +#include +#include #include #include @@ -1067,6 +1070,40 @@ bool TP_MAKE_THISCALL(HookSpawnActorInWorld, Actor) TP_THIS_FUNCTION(TDamageActor, bool, Actor, float aDamage, Actor* apHitter, bool aKillMove); static TDamageActor* RealDamageActor = nullptr; +// The engine's HitData, only the parts the remote player hit path touches. Offsets are from SkyrimSE 1.6.1170 +// (ctor 43995, HitData::Populate 44001, armor and block step 44014, Actor::ProcessHitData 38586). +namespace HitDataLayout +{ +constexpr size_t kSize = 0x98; +constexpr size_t kAggressorHandle = 0x18; +constexpr size_t kTargetHandle = 0x1C; +constexpr size_t kWeapon = 0x30; // TESObjectWEAP* +constexpr size_t kTotalDamage = 0x50; +constexpr size_t kPhysicalDamage = 0x54; // total damage before the target's armor and block +constexpr size_t kFlags = 0x80; +// Flags the armor and block step reads: bash, power attack, left hand. +constexpr uint32_t kSentFlags = (1u << 14) | (1u << 16) | (1u << 17); + +template T& At(void* apHitData, size_t aOffset) noexcept +{ + return *reinterpret_cast(static_cast(apHitData) + aOffset); +} +} // namespace HitDataLayout + +TP_THIS_FUNCTION(TProcessHitData, void, Actor, void* apHitData); +static TProcessHitData* RealProcessHitData = nullptr; + +// The HitData the game is applying on this thread, for HookDamageActor(). +static thread_local void* s_pProcessedHitData = nullptr; + +void TP_MAKE_THISCALL(HookProcessHitData, Actor, void* apHitData) +{ + void* const pPrevious = s_pProcessedHitData; + s_pProcessedHitData = apHitData; + TiltedPhoques::ThisCall(RealProcessHitData, apThis, apHitData); + s_pProcessedHitData = pPrevious; +} + // TODO: this is flawed, since it does not account for invulnerable actors bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bool aKillMove) { @@ -1078,21 +1115,49 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo float currentHealth = apThis->GetActorValue(ActorValueInfo::kHealth); bool wouldKill = (currentHealth - realDamage) <= 0.f; + // Applies the hit here and sends the health change to the others, logged for the damage diagnostics. + const auto applyHere = [&](const char* acpPath) -> bool + { + const uint64_t cHitId = DamageDiagnostics::OnLocalHit(apThis, apHitter, aDamage, realDamage, acpPath); + World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); + const bool cResult = TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + DamageDiagnostics::OnDamageApplied(cHitId, apThis, currentHealth); + return cResult; + }; + const auto* pExHittee = apThis->GetExtension(); if (pExHittee->IsLocalPlayer()) { - if (!World::Get().GetServerSettings().PvpEnabled) - { - // A remote player's summon counts too, its copy swings at whatever the owner's client tells it to. - if (apHitter && (apHitter->GetExtension()->IsRemotePlayer() || (apHitter->GetExtension()->IsRemote() && apHitter->IsPlayerSummon()))) - return false; - } + // A remote player or an NPC owned by another client: its copy here only replays the swing and misses most + // of the time. The attacker's own client sees the real hit and sends it, it lands through + // Actor::ApplyHitFromOwner(). That client also applies the PvP setting. + if (apHitter && apHitter->GetExtension()->IsRemote()) + return false; - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hittee-local-player"); } else if (pExHittee->IsRemotePlayer()) { + // The local player or an NPC owned here landed a weapon hit on another player. Send the damage from before + // armor and block to that player's client, which applies its own armor, block, perks and difficulty, like + // a hit from an enemy, see Actor::ApplyHitFromOwner(). + void* const pHitData = s_pProcessedHitData; + if (pHitData && apHitter && apHitter->GetExtension()->IsLocal()) + { + // ProcessHitData calls this once per hit, don't send it again from anything it triggers. + s_pProcessedHitData = nullptr; + + const bool cFromPlayer = apHitter->GetExtension()->IsPlayer() || apHitter->IsPlayerSummon(); + if (World::Get().GetServerSettings().PvpEnabled || !cFromPlayer) + { + using namespace HitDataLayout; + const auto* pWeapon = At(pHitData, kWeapon); + DamageDiagnostics::OnPlayerHitSent(apThis, apHitter, At(pHitData, kPhysicalDamage)); + World::Get().GetRunner().Trigger(PlayerHitEvent{apThis->formID, apHitter->formID, pWeapon ? pWeapon->formID : 0, + At(pHitData, kPhysicalDamage), At(pHitData, kFlags) & kSentFlags}); + } + } + return wouldKill; } @@ -1101,8 +1166,7 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo const auto* pExHitter = apHitter->GetExtension(); if (pExHitter->IsLocalPlayer()) { - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hitter-local-player"); } if (pExHitter->IsRemotePlayer()) { @@ -1112,8 +1176,7 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo if (pExHittee->IsLocal()) { - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hittee-local-npc"); } else { @@ -1121,6 +1184,50 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo } } +void Actor::ApplyHitFromOwner(Actor* apAttacker, TESObjectWEAP* apWeapon, float aPhysicalDamage, uint32_t aHitFlags) noexcept +{ + using namespace HitDataLayout; + + TP_THIS_FUNCTION(THitDataCtor, void*, void); + TP_THIS_FUNCTION(THitDataDtor, void, void); + TP_THIS_FUNCTION(TCalcArmorAndBlock, void, void, bool aSkipBlock); + using TApplyPerkEntryPoint = void(uint32_t aEntry, Actor* apOwner, ...); + + POINTER_SKYRIMSE(THitDataCtor, s_hitDataCtor, 43995); + POINTER_SKYRIMSE(THitDataDtor, s_hitDataDtor, 43997); + POINTER_SKYRIMSE(TCalcArmorAndBlock, s_calcArmorAndBlock, 44014); + POINTER_SKYRIMSE(TApplyPerkEntryPoint, s_applyPerkEntryPoint, 23526); + constexpr uint32_t kModIncomingDamage = 0x24; + + // Rebuild the hit against this, the real player: the engine's armor and block step reads this actor's own armor + // rating, skills, block, perks and whatever mods change, which the attacker's copy of the player doesn't have. + alignas(16) uint8_t hitData[kSize]{}; + TiltedPhoques::ThisCall(s_hitDataCtor, hitData); + At(hitData, kAggressorHandle) = apAttacker ? apAttacker->GetHandle().handle.iBits : 0; + At(hitData, kTargetHandle) = GetHandle().handle.iBits; + At(hitData, kWeapon) = apWeapon; + At(hitData, kFlags) |= aHitFlags & kSentFlags; + At(hitData, kTotalDamage) = aPhysicalDamage; + + TiltedPhoques::ThisCall(s_calcArmorAndBlock, hitData, false); + + // The game applies this one before armor, from the target's perks, so the attacker's copy couldn't. + const float cAfterArmor = At(hitData, kTotalDamage); + float damage = cAfterArmor; + if (apAttacker && apWeapon) + s_applyPerkEntryPoint.Get()(kModIncomingDamage, this, apAttacker, apWeapon, &damage); + + TiltedPhoques::ThisCall(s_hitDataDtor, hitData); + + // The game's own damage applies the difficulty multiplier and handles bleedout and death like for a local hit. + // Calls the original so the hook doesn't treat it as a new hit, other clients get the health from the sync. + const float cHealthBefore = GetActorValue(ActorValueInfo::kHealth); + if (damage > 0.f) + TiltedPhoques::ThisCall(RealDamageActor, this, damage, apAttacker, false); + + DamageDiagnostics::OnPlayerHitReceived(apAttacker, aPhysicalDamage, cAfterArmor, damage, cHealthBefore, GetActorValue(ActorValueInfo::kHealth)); +} + TP_THIS_FUNCTION(TApplyActorEffect, void, ActiveEffect, Actor* apTarget, float aEffectValue, unsigned int unk1); static TApplyActorEffect* RealApplyActorEffect = nullptr; @@ -1488,6 +1595,7 @@ static TiltedPhoques::Initializer s_actorHooks( POINTER_SKYRIMSE(TForceState, s_ForceState, 37313); POINTER_SKYRIMSE(TSpawnActorInWorld, s_SpawnActorInWorld, 19742); POINTER_SKYRIMSE(TDamageActor, s_damageActor, 37335); + POINTER_SKYRIMSE(TProcessHitData, s_processHitData, 38586); POINTER_SKYRIMSE(TApplyActorEffect, s_applyActorEffect, 35086); POINTER_SKYRIMSE(TRegenAttributes, s_regenAttributes, 37448); POINTER_SKYRIMSE(TAddInventoryItem, s_addInventoryItem, 37525); @@ -1517,6 +1625,7 @@ static TiltedPhoques::Initializer s_actorHooks( RealForceState = s_ForceState.Get(); RealSpawnActorInWorld = s_SpawnActorInWorld.Get(); RealDamageActor = s_damageActor.Get(); + RealProcessHitData = s_processHitData.Get(); RealApplyActorEffect = s_applyActorEffect.Get(); RealRegenAttributes = s_regenAttributes.Get(); RealAddInventoryItem = s_addInventoryItem.Get(); @@ -1545,6 +1654,7 @@ static TiltedPhoques::Initializer s_actorHooks( TP_HOOK(&RealForceState, HookForceState); TP_HOOK(&RealSpawnActorInWorld, HookSpawnActorInWorld); TP_HOOK(&RealDamageActor, HookDamageActor); + TP_HOOK(&RealProcessHitData, HookProcessHitData); TP_HOOK(&RealApplyActorEffect, HookApplyActorEffect); TP_HOOK(&RealRegenAttributes, HookRegenAttributes); TP_HOOK(&RealAddInventoryItem, HookAddInventoryItem); diff --git a/Code/client/Games/Skyrim/Actor.h b/Code/client/Games/Skyrim/Actor.h index babb25994..f0d7a0b59 100644 --- a/Code/client/Games/Skyrim/Actor.h +++ b/Code/client/Games/Skyrim/Actor.h @@ -16,6 +16,7 @@ struct TESNPC; struct TESRace; +struct TESObjectWEAP; struct ExActor; struct ExPlayerCharacter; struct ActorExtension; @@ -242,6 +243,7 @@ struct Actor : TESObjectREFR void DispelAllSpells(bool aNow = false) noexcept; void Reset() noexcept; void Kill() noexcept; + void ApplyHitFromOwner(Actor* apAttacker, TESObjectWEAP* apWeapon, float aPhysicalDamage, uint32_t aHitFlags) noexcept; void Respawn() noexcept; void PickUpObject(TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2) noexcept; void SpeakSound(const char* pFile); diff --git a/Code/client/Services/ActorValueService.h b/Code/client/Services/ActorValueService.h index cab85f225..221dfa741 100644 --- a/Code/client/Services/ActorValueService.h +++ b/Code/client/Services/ActorValueService.h @@ -16,6 +16,8 @@ struct NotifyActorValueChanges; struct NotifyActorMaxValueChanges; struct NotifyHealthChangeBroadcast; struct NotifyDeathStateChange; +struct PlayerHitEvent; +struct NotifyPlayerHit; struct Actor; @@ -84,6 +86,14 @@ struct ActorValueService * @brief Receives death state changes and applies them locally. */ void OnDeathStateChange(const NotifyDeathStateChange& acEvent) const noexcept; + /** + * @brief Sends a hit landed on a remote player to that player's client. + */ + void OnPlayerHit(const PlayerHitEvent& acEvent) const noexcept; + /** + * @brief Applies a hit another client landed on the local player. + */ + void OnNotifyPlayerHit(const NotifyPlayerHit& acMessage) const noexcept; /** * @brief Checks and broadcasts new actor values. @@ -103,6 +113,13 @@ struct ActorValueService * @brief Checks and broadcasts death state changes. */ void RunDeathStateUpdates() noexcept; + /** + * @brief Sends the local player's health when it changed, more often than the other actor values. + * + * Other clients take a player's health only from this sync, so hits, regen and heals all show up + * within a quarter second for everyone nearby, party or not. + */ + void RunPlayerHealthUpdates() noexcept; /** * @brief Checks and broadcasts actor values. diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 59a4fde11..478f0b232 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -8,12 +8,15 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include @@ -23,6 +26,8 @@ #include #include #include +#include +#include #include @@ -40,6 +45,8 @@ ActorValueService::ActorValueService(World& aWorld, entt::dispatcher& aDispatche m_dispatcher.sink().connect<&ActorValueService::OnHealthChange>(this); m_dispatcher.sink().connect<&ActorValueService::OnHealthChangeBroadcast>(this); m_dispatcher.sink().connect<&ActorValueService::OnDeathStateChange>(this); + m_dispatcher.sink().connect<&ActorValueService::OnPlayerHit>(this); + m_dispatcher.sink().connect<&ActorValueService::OnNotifyPlayerHit>(this); } void ActorValueService::CreateActorValuesComponent(const entt::entity aEntity, Actor* apActor) noexcept @@ -99,6 +106,7 @@ void ActorValueService::OnActorRemoved(const ActorRemovedEvent& acEvent) noexcep void ActorValueService::OnUpdate(const UpdateEvent& acEvent) noexcept { RunSmallHealthUpdates(); + RunPlayerHealthUpdates(); RunDeathStateUpdates(); RunActorValuesUpdates(); } @@ -235,6 +243,51 @@ void ActorValueService::RunSmallHealthUpdates() noexcept } } +void ActorValueService::RunPlayerHealthUpdates() noexcept +{ + static std::chrono::steady_clock::time_point lastSendTimePoint; + constexpr auto cDelayBetweenUpdates = 250ms; + + const auto now = std::chrono::steady_clock::now(); + if (now - lastSendTimePoint < cDelayBetweenUpdates) + return; + + lastSendTimePoint = now; + + if (!m_transport.IsConnected()) + return; + + auto view = m_world.view(); + for (auto entity : view) + { + const auto& formIdComponent = view.get(entity); + // 0x14 is the player character. + if (formIdComponent.Id != 0x14) + continue; + + Actor* const pActor = Cast(TESForm::GetById(formIdComponent.Id)); + if (!pActor) + return; + + auto& actorValuesComponent = view.get(entity); + const float cHealth = pActor->GetActorValue(ActorValueInfo::kHealth); + float& sentHealth = actorValuesComponent.CurrentActorValues.ActorValuesList[ActorValueInfo::kHealth]; + if (cHealth == sentHealth) + return; + + // Also keeps BroadcastActorValues() from sending the same value again. + sentHealth = cHealth; + + const auto& localComponent = view.get(entity); + RequestActorValueChanges request; + request.Id = localComponent.Id; + request.OwnershipEpoch = localComponent.OwnershipEpoch; + request.Values.insert({ActorValueInfo::kHealth, cHealth}); + m_transport.Send(request); + return; + } +} + void ActorValueService::RunDeathStateUpdates() noexcept { static std::chrono::steady_clock::time_point lastSendTimePoint; @@ -297,10 +350,17 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas return; } + DamageDiagnostics::OnHealthBroadcastReceived(acMessage.Id, acMessage.DeltaHealth); + // A dead body with health can end up back on its feet, and there's nothing to take that health away again. if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && pActor->IsDead()) return; + // A player's health is owned by their own client, hits on it arrive as NotifyPlayerHit. Copies of other players + // take their owner's health from the actor value sync. + if (pActor->GetExtension()->IsPlayer()) + return; + const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) { @@ -349,6 +409,13 @@ void ActorValueService::OnActorValueChanges(const NotifyActorValueChanges& acMes for (const auto& [key, value] : acMessage.Values) { + // Regen and heals aren't sent as deltas, so a remote player's health has to follow their own client's value. + if (key == ActorValueInfo::kHealth && pActor->GetExtension()->IsRemotePlayer()) + { + pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, key, value); + continue; + } + // Syncing dragon souls triggers "Dragon soul collected" event if (key == ActorValueInfo::kDragonSouls || key == ActorValueInfo::kHealth) continue; @@ -402,3 +469,55 @@ void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessa if (!m_world.any_of(*entity)) RemoteDeathSystem::Apply(pActor, acMessage.IsDead); } + +namespace +{ +std::optional ServerIdOfForm(World& aWorld, uint32_t aFormId) noexcept +{ + auto view = aWorld.view(); + const auto it = std::find_if(view.begin(), view.end(), [view, aFormId](entt::entity aEntity) { return view.get(aEntity).Id == aFormId; }); + if (it == view.end()) + return std::nullopt; + return Utils::GetServerId(*it); +} +} // namespace + +void ActorValueService::OnPlayerHit(const PlayerHitEvent& acEvent) const noexcept +{ + if (!m_transport.IsConnected()) + return; + + const auto cHitteeId = ServerIdOfForm(m_world, acEvent.HitteeId); + if (!cHitteeId) + { + spdlog::warn("{}: no server id for hit player {:X}", __FUNCTION__, acEvent.HitteeId); + return; + } + + RequestPlayerHit request; + request.HitteeId = *cHitteeId; + request.AttackerId = ServerIdOfForm(m_world, acEvent.AttackerId).value_or(0); + if (acEvent.WeaponId) + World::Get().GetModSystem().GetServerModId(acEvent.WeaponId, request.WeaponId); + request.Damage = acEvent.Damage; + request.Flags = acEvent.Flags; + + m_transport.Send(request); +} + +void ActorValueService::OnNotifyPlayerHit(const NotifyPlayerHit& acMessage) const noexcept +{ + Actor* pActor = Utils::GetByServerId(acMessage.HitteeId); + if (!pActor || !pActor->GetExtension()->IsLocalPlayer()) + { + spdlog::warn("{}: hit for {:X} is not for the local player", __FUNCTION__, acMessage.HitteeId); + return; + } + + Actor* pAttacker = acMessage.AttackerId ? Utils::GetByServerId(acMessage.AttackerId) : nullptr; + TESObjectWEAP* pWeapon = nullptr; + if (const uint32_t cWeaponId = World::Get().GetModSystem().GetGameId(acMessage.WeaponId)) + pWeapon = Cast(TESForm::GetById(cWeaponId)); + + pActor->ApplyHitFromOwner(pAttacker, pWeapon, acMessage.Damage, acMessage.Flags); +} diff --git a/Code/client/Systems/DamageDiagnostics.cpp b/Code/client/Systems/DamageDiagnostics.cpp new file mode 100644 index 000000000..a4dd25b0d --- /dev/null +++ b/Code/client/Systems/DamageDiagnostics.cpp @@ -0,0 +1,178 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace DamageDiagnostics +{ +namespace +{ +using Clock = std::chrono::steady_clock; + +// How long events are kept to compare with later ones. +constexpr auto kHistory = std::chrono::milliseconds(2000); +// The same hit twice closer than this counts as one hit applied twice. +constexpr auto kSameHitWindow = std::chrono::milliseconds(150); +// Hits below this are damage-over-time ticks, too many and too small to log. +constexpr float kMinLoggedDamage = 1.f; + +struct Record +{ + uint64_t Id = 0; + Clock::time_point Time{}; + uint32_t SourceId = 0; + uint32_t TargetId = 0; + float Damage = 0.f; +}; + +std::mutex s_lock; +uint64_t s_nextId = 1; +std::deque s_localHits; +std::deque s_receivedHits; +std::deque s_broadcasts; + +long long Ms(Clock::duration aDuration) noexcept +{ + return std::chrono::duration_cast(aDuration).count(); +} + +void Prune(std::deque& aRecords, Clock::time_point aNow) noexcept +{ + while (!aRecords.empty() && aNow - aRecords.front().Time > kHistory) + aRecords.pop_front(); +} + +bool Close(float aA, float aB) noexcept +{ + return std::abs(aA - aB) <= std::max(0.5f, std::max(std::abs(aA), std::abs(aB)) * 0.05f); +} + +// Pushes the record and returns the earlier one it duplicates, if any. +std::optional Track(std::deque& aRecords, const Record& acRecord) noexcept +{ + Prune(aRecords, acRecord.Time); + std::optional twin; + for (auto it = aRecords.rbegin(); it != aRecords.rend(); ++it) + { + if (acRecord.Time - it->Time > kSameHitWindow) + break; + if (it->SourceId == acRecord.SourceId && it->TargetId == acRecord.TargetId && Close(it->Damage, acRecord.Damage)) + { + twin = *it; + break; + } + } + aRecords.push_back(acRecord); + return twin; +} + +const char* Role(Actor* apActor) noexcept +{ + if (!apActor) + return "none"; + ActorExtension* pExtension = apActor->GetExtension(); + if (!pExtension) + return "no-ext"; + if (pExtension->IsLocalPlayer()) + return "local player"; + if (pExtension->IsRemotePlayer()) + return "remote player"; + return pExtension->IsRemote() ? "remote npc" : "local npc"; +} + +bool IsPlayer(Actor* apActor) noexcept +{ + return apActor && apActor->GetExtension() && apActor->GetExtension()->IsPlayer(); +} + +std::string Describe(Actor* apActor) +{ + if (!apActor) + return "none"; + const char* cpName = apActor->baseForm ? apActor->baseForm->GetName() : nullptr; + return fmt::format("{}({:X}, {})", cpName && *cpName ? cpName : "?", apActor->formID, Role(apActor)); +} + +int32_t Difficulty() noexcept +{ + const auto* pPlayer = PlayerCharacter::Get(); + return pPlayer ? pPlayer->difficulty : -1; +} +} // namespace + +uint64_t OnLocalHit(Actor* apHittee, Actor* apHitter, float aRawDamage, float aRealDamage, const char* acpPath) noexcept +{ + if (!apHittee || aRealDamage < kMinLoggedDamage || !(IsPlayer(apHittee) || IsPlayer(apHitter))) + return 0; + + std::scoped_lock lock(s_lock); + const Record cRecord{s_nextId++, Clock::now(), apHitter ? apHitter->formID : 0, apHittee->formID, aRawDamage}; + const auto cTwin = Track(s_localHits, cRecord); + + spdlog::info( + "[DmgDiag] #{} HIT path={} hitter={} hittee={} raw={:.1f} real={:.1f} hp={:.1f}/{:.1f} diff={}", cRecord.Id, acpPath, Describe(apHitter), Describe(apHittee), aRawDamage, + aRealDamage, apHittee->GetActorValue(ActorValueInfo::kHealth), apHittee->GetActorPermanentValue(ActorValueInfo::kHealth), Difficulty()); + if (cTwin) + spdlog::warn("[DmgDiag] #{} FLAG DUP_LOCAL_HIT: same hitter, hittee and damage as #{} {} ms earlier", cRecord.Id, cTwin->Id, Ms(cRecord.Time - cTwin->Time)); + + return cRecord.Id; +} + +void OnDamageApplied(uint64_t aHitId, Actor* apHittee, float aHealthBefore) noexcept +{ + if (!apHittee || aHitId == 0) + return; + + const float cHealthAfter = apHittee->GetActorValue(ActorValueInfo::kHealth); + spdlog::info("[DmgDiag] #{} APPLIED hp {:.1f} -> {:.1f}", aHitId, aHealthBefore, cHealthAfter); +} + +void OnPlayerHitSent(Actor* apHittee, Actor* apAttacker, float aPhysicalDamage) noexcept +{ + if (!apHittee || aPhysicalDamage < kMinLoggedDamage) + return; + + std::scoped_lock lock(s_lock); + const uint64_t cId = s_nextId++; + spdlog::info( + "[DmgDiag] #{} SENT hit attacker={} victim={} before-armor={:.1f}, the victim's client applies armor, block and difficulty", cId, Describe(apAttacker), Describe(apHittee), + aPhysicalDamage); +} + +void OnPlayerHitReceived(Actor* apAttacker, float aPhysicalDamage, float aAfterArmor, float aAfterPerks, float aHealthBefore, float aHealthAfter) noexcept +{ + if (aPhysicalDamage < kMinLoggedDamage) + return; + + std::scoped_lock lock(s_lock); + const Record cRecord{s_nextId++, Clock::now(), apAttacker ? apAttacker->formID : 0, 0x14, aPhysicalDamage}; + const auto cTwin = Track(s_receivedHits, cRecord); + + spdlog::info( + "[DmgDiag] #{} RECV hit attacker={} before-armor={:.1f} after-armor-block={:.1f} after-perks={:.1f} hp {:.1f} -> {:.1f} diff={}", cRecord.Id, Describe(apAttacker), + aPhysicalDamage, aAfterArmor, aAfterPerks, aHealthBefore, aHealthAfter, Difficulty()); + if (cTwin) + spdlog::warn("[DmgDiag] #{} FLAG DUP_RECEIVED_HIT: same attacker and damage as #{} {} ms earlier", cRecord.Id, cTwin->Id, Ms(cRecord.Time - cTwin->Time)); +} + +void OnHealthBroadcastReceived(uint32_t aServerId, float aDelta) noexcept +{ + if (std::abs(aDelta) < kMinLoggedDamage) + return; + + std::scoped_lock lock(s_lock); + const Record cRecord{s_nextId++, Clock::now(), 0, aServerId, aDelta}; + if (const auto cTwin = Track(s_broadcasts, cRecord)) + spdlog::warn("[DmgDiag] FLAG DUP_BROADCAST: health delta {:.1f} for server id {:X} arrived twice within {} ms", aDelta, aServerId, Ms(cRecord.Time - cTwin->Time)); +} +} // namespace DamageDiagnostics diff --git a/Code/client/Systems/DamageDiagnostics.h b/Code/client/Systems/DamageDiagnostics.h new file mode 100644 index 000000000..b3a51ed5f --- /dev/null +++ b/Code/client/Systems/DamageDiagnostics.h @@ -0,0 +1,24 @@ +#pragma once + +struct Actor; + +/** + * @brief Client-only logging of damage involving players, so playtesters can check for hits applied twice. + * + * Every line starts with "[DmgDiag]" in the normal client log. Lines that look wrong are warnings carrying "FLAG" and + * the reason. Damage-over-time ticks under 1 point (fire cloaks and the like) are not logged. Nothing here changes what + * is sent or how damage is applied. + */ +namespace DamageDiagnostics +{ +// HookDamageActor applies a hit on this client. Returns the hit's number for OnDamageApplied(). +uint64_t OnLocalHit(Actor* apHittee, Actor* apHitter, float aRawDamage, float aRealDamage, const char* acpPath) noexcept; +// The game applied the hit OnLocalHit() numbered. aHealthBefore is the hittee's health before it. +void OnDamageApplied(uint64_t aHitId, Actor* apHittee, float aHealthBefore) noexcept; +// A weapon hit on a remote player is sent to that player's client. +void OnPlayerHitSent(Actor* apHittee, Actor* apAttacker, float aPhysicalDamage) noexcept; +// Another client's weapon hit is applied to the local player. +void OnPlayerHitReceived(Actor* apAttacker, float aPhysicalDamage, float aAfterArmor, float aAfterPerks, float aHealthBefore, float aHealthAfter) noexcept; +// A health delta from another client is applied to apActor. +void OnHealthBroadcastReceived(uint32_t aServerId, float aDelta) noexcept; +} // namespace DamageDiagnostics diff --git a/Code/common/xmake.lua b/Code/common/xmake.lua index 37b059a08..453c8b88e 100644 --- a/Code/common/xmake.lua +++ b/Code/common/xmake.lua @@ -1,6 +1,12 @@ +local cBuildVersion = get_config("build_version") + target("CommonLib") - add_configfiles("BuildInfo.h.in") + if cBuildVersion and cBuildVersion ~= "" then + add_configfiles("BuildInfo.h.in", {variables = {GIT_TAG = cBuildVersion}}) + else + add_configfiles("BuildInfo.h.in") + end set_kind("static") set_group("common") add_includedirs(".", "../", "../../build", {public = true}) diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index 1c33ad8f3..80fb3567a 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -59,6 +59,7 @@ #include #include #include +#include #include #include #include @@ -84,7 +85,7 @@ struct ClientMessageFactory AuthenticationRequest, AssignCharacterRequest, ClientReferencesMoveRequest, EnterInteriorCellRequest, RequestInventoryChanges, RequestInventorySnapshot, RequestFactionsChanges, RequestQuestUpdate, PartyInviteRequest, PartyAcceptInviteRequest, PartyLeaveRequest, PartyCreateRequest, PartyChangeLeaderRequest, PartyKickRequest, TradeInviteRequest, TradeInviteResponseRequest, TradeOfferUpdateRequest, TradeSetReadyRequest, TradeCancelRequest, TradeInventorySyncRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, RequestOwnershipTransfer, RequestOwnershipClaim, RequestObjectInventoryChanges, SpellCastRequest, ProjectileLaunchRequest, InterruptCastRequest, AddTargetRequest, ScriptAnimationRequest, DrawWeaponRequest, MountRequest, NewPackageRequest, RequestRespawn, SyncExperienceRequest, RequestEquipmentChanges, SendChatMessageRequest, TeleportCommandRequest, PlayerRespawnRequest, DialogueRequest, SubtitleRequest, PlayerDialogueRequest, PlayerLevelRequest, TeleportRequest, RequestPlayerHealthUpdate, RequestWeatherChange, RequestCurrentWeather, RequestSetWaypoint, - RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, RequestTimeSkip, DropItemRequest, DroppedItemMoveRequest, PickUpDroppedItemRequest, VoiceDataRequest, VoiceStateRequest, SendModMessageRequest, PlayerPerksRequest>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, RequestTimeSkip, DropItemRequest, DroppedItemMoveRequest, PickUpDroppedItemRequest, VoiceDataRequest, VoiceStateRequest, SendModMessageRequest, PlayerPerksRequest, RequestPlayerHit>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/NotifyPlayerHit.cpp b/Code/encoding/Messages/NotifyPlayerHit.cpp new file mode 100644 index 000000000..2c23dddc6 --- /dev/null +++ b/Code/encoding/Messages/NotifyPlayerHit.cpp @@ -0,0 +1,21 @@ +#include + +void NotifyPlayerHit::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, HitteeId); + Serialization::WriteVarInt(aWriter, AttackerId); + WeaponId.Serialize(aWriter); + Serialization::WriteFloat(aWriter, Damage); + Serialization::WriteVarInt(aWriter, Flags); +} + +void NotifyPlayerHit::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + HitteeId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + AttackerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + WeaponId.Deserialize(aReader); + Damage = Serialization::ReadFloat(aReader); + Flags = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/NotifyPlayerHit.h b/Code/encoding/Messages/NotifyPlayerHit.h new file mode 100644 index 000000000..9993631a4 --- /dev/null +++ b/Code/encoding/Messages/NotifyPlayerHit.h @@ -0,0 +1,36 @@ +#pragma once + +#include "Message.h" +#include + +/** + * A hit the attacker's client saw land on another player. The damage is before the victim's armor and block, which + * the victim's own client applies, see Actor::ApplyHitFromOwner(). + */ +struct NotifyPlayerHit final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyPlayerHit; + + NotifyPlayerHit() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyPlayerHit& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && HitteeId == acRhs.HitteeId && AttackerId == acRhs.AttackerId && WeaponId == acRhs.WeaponId && Damage == acRhs.Damage && + Flags == acRhs.Flags; + } + + // Server ids, AttackerId is 0 when the attacker isn't synced. + uint32_t HitteeId{}; + uint32_t AttackerId{}; + GameId WeaponId{}; + // HitData physical damage: after the attacker's perks, sneak and critical, before the target's armor and block. + float Damage{}; + // The HitData flags the armor and block step reads (power attack, bash, left hand). + uint32_t Flags{}; +}; diff --git a/Code/encoding/Messages/RequestPlayerHit.cpp b/Code/encoding/Messages/RequestPlayerHit.cpp new file mode 100644 index 000000000..53d65a3f2 --- /dev/null +++ b/Code/encoding/Messages/RequestPlayerHit.cpp @@ -0,0 +1,21 @@ +#include + +void RequestPlayerHit::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, HitteeId); + Serialization::WriteVarInt(aWriter, AttackerId); + WeaponId.Serialize(aWriter); + Serialization::WriteFloat(aWriter, Damage); + Serialization::WriteVarInt(aWriter, Flags); +} + +void RequestPlayerHit::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + HitteeId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + AttackerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + WeaponId.Deserialize(aReader); + Damage = Serialization::ReadFloat(aReader); + Flags = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/RequestPlayerHit.h b/Code/encoding/Messages/RequestPlayerHit.h new file mode 100644 index 000000000..7a9f5df89 --- /dev/null +++ b/Code/encoding/Messages/RequestPlayerHit.h @@ -0,0 +1,36 @@ +#pragma once + +#include "Message.h" +#include + +/** + * A hit the attacker's client saw land on another player. The damage is before the victim's armor and block, which + * the victim's own client applies, see Actor::ApplyHitFromOwner(). + */ +struct RequestPlayerHit final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kRequestPlayerHit; + + RequestPlayerHit() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const RequestPlayerHit& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && HitteeId == acRhs.HitteeId && AttackerId == acRhs.AttackerId && WeaponId == acRhs.WeaponId && Damage == acRhs.Damage && + Flags == acRhs.Flags; + } + + // Server ids, AttackerId is 0 when the attacker isn't synced. + uint32_t HitteeId{}; + uint32_t AttackerId{}; + GameId WeaponId{}; + // HitData physical damage: after the attacker's perks, sneak and critical, before the target's armor and block. + float Damage{}; + // The HitData flags the armor and block step reads (power attack, bash, left hand). + uint32_t Flags{}; +}; diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index 7022b2dee..434dd0f47 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -57,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -86,7 +87,7 @@ struct ServerMessageFactory AuthenticationResponse, AssignCharacterResponse, ServerReferencesMoveRequest, ServerTimeSettings, CharacterSpawnRequest, NotifyInventoryChanges, NotifyInventorySnapshot, StringCacheUpdate, NotifyFactionsChanges, NotifyRemoveCharacter, NotifyQuestUpdate, NotifyPlayerList, NotifyPartyInfo, NotifyPartyInvite, NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyTradeInvite, NotifyTradeStarted, NotifyTradeState, NotifyTradeCancel, NotifyTradeComplete, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, NotifyProjectileLaunch, NotifyInterruptCast, NotifyAddTarget, NotifyScriptAnimation, NotifyDrawWeapon, NotifyMount, NotifyNewPackage, NotifyRespawn, NotifySyncExperience, NotifyEquipmentChanges, NotifyChatMessageBroadcast, TeleportCommandResponse, NotifyPlayerRespawn, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyActorTeleport, NotifyPlayerLeft, NotifyPlayerJoined, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyPlayerLevel, NotifyPlayerCellChanged, NotifyTeleport, NotifyPlayerHealthUpdate, NotifySettingsChange, - NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyRemoveObjects, DropItemResponse, NotifyDroppedItemsSpawn, NotifyDroppedItemMove, NotifyDroppedItemsRemove, NotifyVoiceData, NotifyModMessage, NotifyPlayerPerks>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyRemoveObjects, DropItemResponse, NotifyDroppedItemsSpawn, NotifyDroppedItemMove, NotifyDroppedItemsRemove, NotifyVoiceData, NotifyModMessage, NotifyPlayerPerks, NotifyPlayerHit>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index ac1cd7767..9fa7d8f98 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -69,6 +69,7 @@ enum ClientOpcode : unsigned char kVoiceStateRequest, kSendModMessageRequest, kPlayerPerksRequest, + kRequestPlayerHit, kClientOpcodeMax }; @@ -143,5 +144,6 @@ enum ServerOpcode : unsigned char kNotifyVoiceData, kNotifyModMessage, kNotifyPlayerPerks, + kNotifyPlayerHit, kServerOpcodeMax }; diff --git a/Code/server/Services/ActorValueService.cpp b/Code/server/Services/ActorValueService.cpp index bf711b5dc..e35fc733e 100644 --- a/Code/server/Services/ActorValueService.cpp +++ b/Code/server/Services/ActorValueService.cpp @@ -6,10 +6,13 @@ #include #include #include +#include "Game/Player.h" #include #include #include #include +#include +#include ActorValueService::ActorValueService(World& aWorld, entt::dispatcher& aDispatcher) noexcept : m_world(aWorld) @@ -18,6 +21,7 @@ ActorValueService::ActorValueService(World& aWorld, entt::dispatcher& aDispatche m_updateMaxValueConnection = aDispatcher.sink>().connect<&ActorValueService::OnActorMaxValueChanges>(this); m_updateDeltaHealthConnection = aDispatcher.sink>().connect<&ActorValueService::OnHealthChangeBroadcast>(this); m_deathStateConnection = aDispatcher.sink>().connect<&ActorValueService::OnDeathStateChange>(this); + m_playerHitConnection = aDispatcher.sink>().connect<&ActorValueService::OnPlayerHit>(this); } void ActorValueService::OnActorValueChanges(const PacketEvent& acMessage) const noexcept @@ -87,7 +91,8 @@ void ActorValueService::OnHealthChangeBroadcast(const PacketEvent(*it); auto currentHealth = actorValuesComponent.CurrentActorValues.ActorValuesList[24]; - actorValuesComponent.CurrentActorValues.ActorValuesList[24] = currentHealth - message.DeltaHealth; + // DeltaHealth is negative for damage. The stored health is what players who walk in later spawn the actor with. + actorValuesComponent.CurrentActorValues.ActorValuesList[24] = currentHealth + message.DeltaHealth; } NotifyHealthChangeBroadcast notify; @@ -123,3 +128,27 @@ void ActorValueService::OnDeathStateChange(const PacketEventSendToPlayersInRange(notify, cEntity, acMessage.pPlayer)) spdlog::error("{}: SendToPlayersInRange failed", __FUNCTION__); } + +void ActorValueService::OnPlayerHit(const PacketEvent& acMessage) const noexcept +{ + auto& message = acMessage.Packet; + + // Only the hit player's own client applies it, everyone else gets the new health from that client. + auto view = m_world.view(); + const auto it = view.find(static_cast(message.HitteeId)); + if (it == view.end()) + return; + + Player* pOwner = view.get(*it).GetOwner(); + if (!pOwner || pOwner == acMessage.pPlayer) + return; + + NotifyPlayerHit notify; + notify.HitteeId = message.HitteeId; + notify.AttackerId = message.AttackerId; + notify.WeaponId = message.WeaponId; + notify.Damage = message.Damage; + notify.Flags = message.Flags; + + pOwner->Send(notify); +} diff --git a/Code/server/Services/ActorValueService.h b/Code/server/Services/ActorValueService.h index 882533492..6a53e7078 100644 --- a/Code/server/Services/ActorValueService.h +++ b/Code/server/Services/ActorValueService.h @@ -9,6 +9,7 @@ struct RequestActorValueChanges; struct RequestActorMaxValueChanges; struct RequestHealthChangeBroadcast; struct RequestDeathStateChange; +struct RequestPlayerHit; /** * @brief Broadcasts changes in (max) actor values and updates them server side. @@ -27,9 +28,11 @@ struct ActorValueService void OnActorMaxValueChanges(const PacketEvent& acMessage) const noexcept; void OnHealthChangeBroadcast(const PacketEvent& acMessage) const noexcept; void OnDeathStateChange(const PacketEvent& acMessage) const noexcept; + void OnPlayerHit(const PacketEvent& acMessage) const noexcept; entt::scoped_connection m_updateHealthConnection; entt::scoped_connection m_updateMaxValueConnection; entt::scoped_connection m_updateDeltaHealthConnection; entt::scoped_connection m_deathStateConnection; + entt::scoped_connection m_playerHitConnection; }; diff --git a/Code/tests/encoding.cpp b/Code/tests/encoding.cpp index 032092a2a..14d94e528 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -106,6 +106,51 @@ TEST_CASE("Encoding factory", "[encoding.factory]") REQUIRE(pNotify->Perks == notify.Perks); } + { + RequestPlayerHit request; + request.HitteeId = 0x1234; + request.AttackerId = 0x5678; + request.WeaponId = GameId(1, 0x12eb7); + request.Damage = 42.5f; + request.Flags = 1 << 16; + + Buffer::Writer writer(&buff); + request.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ClientMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == request.GetOpcode()); + + auto pRequest = CastUnique(std::move(pMessage)); + REQUIRE(*pRequest == request); + } + + { + NotifyPlayerHit notify; + notify.HitteeId = 0x1234; + notify.AttackerId = 0; + notify.Damage = 7.25f; + notify.Flags = (1 << 14) | (1 << 17); + + Buffer::Writer writer(&buff); + notify.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == notify.GetOpcode()); + + auto pNotify = CastUnique(std::move(pMessage)); + REQUIRE(*pNotify == notify); + } + { DropItemRequest request; request.LocalId = 0xFF001234; diff --git a/xmake.lua b/xmake.lua index 8b1edc6af..9ec4ebb27 100644 --- a/xmake.lua +++ b/xmake.lua @@ -38,6 +38,14 @@ option("skyrim_version") end) option_end() +-- Stamps a fixed version into BuildInfo.h instead of `git describe`, so a local diagnostic build +-- from a branch can still join a server running that release (the protocol has to match). +option("build_version") + set_default("") + set_showmenu(true) + set_description("Version string to report instead of git describe, e.g. v1.8.7-10") +option_end() + if has_config("unitybuild") then add_rules("c.unity_build") add_rules("c++.unity_build", {batchsize = 12})