From b1cfab60c417d4c5b1aa363e77525f2b047a3bff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 18:40:34 +0000 Subject: [PATCH 1/5] Add a build_version option so a branch build can report a release version A diagnostic build from a branch reports e.g. v1.8.7-10-1-ga91c0c49 and the v1.8.7-10 server rejects it. With --build_version=v1.8.7-10 the build stamps that version instead of git describe. The protocol is unchanged on this branch, so the client stays compatible. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB --- Code/common/xmake.lua | 8 +++++++- xmake.lua | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Code/common/xmake.lua b/Code/common/xmake.lua index 37b059a08a..453c8b88e1 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/xmake.lua b/xmake.lua index 8b1edc6af7..9ec4ebb27a 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}) From 83530cb37559df17d44e77e20f2f4f2fb2a68fe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 18:59:44 +0000 Subject: [PATCH 2/5] Let the attacking NPC's owner land hits on players, and sync player health A party member only took an enemy hit when their own replayed copy of the enemy happened to connect: in a test 4 of 10 real hits landed. The client that owns the NPC now sends the raw hit to the player's client, which runs it through the game's damage with its own difficulty, and players ignore hits from replayed NPC copies. Health regen and heals were never synced, so copies of other players only ever lost health (88 HP off after a minute). Copies now take the owner's health from the actor value sync instead of summing deltas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB --- Code/client/Games/Skyrim/Actor.cpp | 20 +++++++++++++++++++ Code/client/Games/Skyrim/Actor.h | 1 + .../Services/Generic/ActorValueService.cpp | 18 +++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 79410e7485..c1f771b706 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -1088,11 +1088,24 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo return false; } + // An NPC owned by another client: its copy here only replays the swing and misses most of the time. + // The owner's client sees the real hit and sends it, it lands through Actor::ApplyHitFromOwner(). + if (apHitter && apHitter->GetExtension()->IsRemote() && !apHitter->GetExtension()->IsPlayer()) + return false; + World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); } else if (pExHittee->IsRemotePlayer()) { + // An NPC owned here hit another player. Send the raw hit to that player's client, which applies it to + // itself with its own difficulty, see Actor::ApplyHitFromOwner(). + if (apHitter && apHitter->GetExtension()->IsLocal() && !apHitter->GetExtension()->IsPlayer() && + (World::Get().GetServerSettings().PvpEnabled || !apHitter->IsPlayerSummon())) + { + World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -aDamage)); + } + return wouldKill; } @@ -1121,6 +1134,13 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo } } +void Actor::ApplyHitFromOwner(float aRawDamage) noexcept +{ + // Runs the game's own damage, so difficulty, bleedout and death work like for a local hit. Calls the original + // directly so the hook doesn't treat it as a new hit. Other clients pick up the new health from the actor value sync. + TiltedPhoques::ThisCall(RealDamageActor, this, aRawDamage, static_cast(nullptr), false); +} + TP_THIS_FUNCTION(TApplyActorEffect, void, ActiveEffect, Actor* apTarget, float aEffectValue, unsigned int unk1); static TApplyActorEffect* RealApplyActorEffect = nullptr; diff --git a/Code/client/Games/Skyrim/Actor.h b/Code/client/Games/Skyrim/Actor.h index babb25994a..632af0ae83 100644 --- a/Code/client/Games/Skyrim/Actor.h +++ b/Code/client/Games/Skyrim/Actor.h @@ -242,6 +242,7 @@ struct Actor : TESObjectREFR void DispelAllSpells(bool aNow = false) noexcept; void Reset() noexcept; void Kill() noexcept; + void ApplyHitFromOwner(float aRawDamage) 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/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 59a4fde111..6c57eb3439 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -301,6 +301,17 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && pActor->IsDead()) return; + // A player's health is owned by their own client. A delta for the local player is a hit that the attacking NPC's + // owner saw, see HookDamageActor(). Copies of other players take their owner's health from the actor value sync. + if (pActor->GetExtension()->IsLocalPlayer()) + { + if (acMessage.DeltaHealth < 0.f) + pActor->ApplyHitFromOwner(-acMessage.DeltaHealth); + return; + } + if (pActor->GetExtension()->IsRemotePlayer()) + return; + const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) { @@ -349,6 +360,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; From 67040fcd0bf3d1bf15565ad394c4d0468ce84a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:13:11 +0000 Subject: [PATCH 3/5] Land player-vs-player hits from the attacker's client, sync player health faster PvP hits only landed when the victim's replayed copy of the attacker happened to connect, so friendly fire went through one way and not the other. The attacker's client now sends the hit like an NPC owner does, and the victim applies it with its own difficulty, the same multiplier as an enemy hit. Other clients now take a player's health only from the owner, so the local player's health is sent every 250 ms when it changes instead of once a second, for everyone nearby whether in a party or not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB --- Code/client/Games/Skyrim/Actor.cpp | 25 ++++------ Code/client/Services/ActorValueService.h | 7 +++ .../Services/Generic/ActorValueService.cpp | 46 +++++++++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index c1f771b706..e06ebfc5ec 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -1081,16 +1081,10 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo 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; - } - - // An NPC owned by another client: its copy here only replays the swing and misses most of the time. - // The owner's client sees the real hit and sends it, it lands through Actor::ApplyHitFromOwner(). - if (apHitter && apHitter->GetExtension()->IsRemote() && !apHitter->GetExtension()->IsPlayer()) + // 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)); @@ -1098,12 +1092,13 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo } else if (pExHittee->IsRemotePlayer()) { - // An NPC owned here hit another player. Send the raw hit to that player's client, which applies it to - // itself with its own difficulty, see Actor::ApplyHitFromOwner(). - if (apHitter && apHitter->GetExtension()->IsLocal() && !apHitter->GetExtension()->IsPlayer() && - (World::Get().GetServerSettings().PvpEnabled || !apHitter->IsPlayerSummon())) + // The local player or an NPC owned here hit another player. Send the raw hit to that player's client, which + // applies it to itself with its own difficulty, like a hit from an enemy, see Actor::ApplyHitFromOwner(). + if (apHitter && apHitter->GetExtension()->IsLocal()) { - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -aDamage)); + const bool cFromPlayer = apHitter->GetExtension()->IsPlayer() || apHitter->IsPlayerSummon(); + if (World::Get().GetServerSettings().PvpEnabled || !cFromPlayer) + World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -aDamage)); } return wouldKill; diff --git a/Code/client/Services/ActorValueService.h b/Code/client/Services/ActorValueService.h index cab85f2252..0136aa9f83 100644 --- a/Code/client/Services/ActorValueService.h +++ b/Code/client/Services/ActorValueService.h @@ -103,6 +103,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 6c57eb3439..a8d79b35eb 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -99,6 +99,7 @@ void ActorValueService::OnActorRemoved(const ActorRemovedEvent& acEvent) noexcep void ActorValueService::OnUpdate(const UpdateEvent& acEvent) noexcept { RunSmallHealthUpdates(); + RunPlayerHealthUpdates(); RunDeathStateUpdates(); RunActorValuesUpdates(); } @@ -235,6 +236,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; From 81d644b56707c4cc32cf05f35f75eb76f4c7181a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:13:11 +0000 Subject: [PATCH 4/5] Subtract damage from the server's stored health instead of adding it The server added health deltas with the wrong sign, so a damaged actor's stored health went up until the owner's next actor value sync. Players who walk in later spawn actors with that stored health. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB --- Code/server/Services/ActorValueService.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Code/server/Services/ActorValueService.cpp b/Code/server/Services/ActorValueService.cpp index bf711b5dcb..c8f27362c9 100644 --- a/Code/server/Services/ActorValueService.cpp +++ b/Code/server/Services/ActorValueService.cpp @@ -87,7 +87,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; From d7e1c9b2dad1a9ce286bebb5d3d3cdbf7f3ce051 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:28:03 +0000 Subject: [PATCH 5/5] Resolve hits on players with the victim's own armor, block and perks Copies of players defend with none of the player's perks, armor skill or block, so a hit computed on the attacker's client was about 3x too strong against a well armored player. The attacker now sends the hit's damage from before armor (HitData physical damage), plus attacker, weapon and the flags the armor step reads, in a new RequestPlayerHit. The victim's client rebuilds the HitData against its real player, runs the game's armor and block step (44014), its Mod Incoming Damage perks, then the damage function with its own difficulty. Also brings the damage diagnostics over, quieter: only hits involving a player and of at least 1 point, flags only for hits or deltas that arrive twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSwpo46aX4h3sn2BYB --- Code/client/Events/PlayerHitEvent.h | 15 ++ Code/client/Games/Skyrim/Actor.cpp | 123 ++++++++++-- Code/client/Games/Skyrim/Actor.h | 3 +- Code/client/Services/ActorValueService.h | 10 + .../Services/Generic/ActorValueService.cpp | 73 ++++++- Code/client/Systems/DamageDiagnostics.cpp | 178 ++++++++++++++++++ Code/client/Systems/DamageDiagnostics.h | 24 +++ Code/encoding/Messages/ClientMessageFactory.h | 3 +- Code/encoding/Messages/NotifyPlayerHit.cpp | 21 +++ Code/encoding/Messages/NotifyPlayerHit.h | 36 ++++ Code/encoding/Messages/RequestPlayerHit.cpp | 21 +++ Code/encoding/Messages/RequestPlayerHit.h | 36 ++++ Code/encoding/Messages/ServerMessageFactory.h | 3 +- Code/encoding/Opcodes.h | 2 + Code/server/Services/ActorValueService.cpp | 28 +++ Code/server/Services/ActorValueService.h | 3 + Code/tests/encoding.cpp | 45 +++++ 17 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 Code/client/Events/PlayerHitEvent.h create mode 100644 Code/client/Systems/DamageDiagnostics.cpp create mode 100644 Code/client/Systems/DamageDiagnostics.h create mode 100644 Code/encoding/Messages/NotifyPlayerHit.cpp create mode 100644 Code/encoding/Messages/NotifyPlayerHit.h create mode 100644 Code/encoding/Messages/RequestPlayerHit.cpp create mode 100644 Code/encoding/Messages/RequestPlayerHit.h diff --git a/Code/client/Events/PlayerHitEvent.h b/Code/client/Events/PlayerHitEvent.h new file mode 100644 index 0000000000..bf6773f793 --- /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 e06ebfc5ec..6ade29aadd 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,6 +1115,16 @@ 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()) { @@ -1087,18 +1134,28 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo 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 hit another player. Send the raw hit to that player's client, which - // applies it to itself with its own difficulty, like a hit from an enemy, see Actor::ApplyHitFromOwner(). - if (apHitter && apHitter->GetExtension()->IsLocal()) + // 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) - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -aDamage)); + { + 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; @@ -1109,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()) { @@ -1120,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 { @@ -1129,11 +1184,48 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo } } -void Actor::ApplyHitFromOwner(float aRawDamage) noexcept +void Actor::ApplyHitFromOwner(Actor* apAttacker, TESObjectWEAP* apWeapon, float aPhysicalDamage, uint32_t aHitFlags) noexcept { - // Runs the game's own damage, so difficulty, bleedout and death work like for a local hit. Calls the original - // directly so the hook doesn't treat it as a new hit. Other clients pick up the new health from the actor value sync. - TiltedPhoques::ThisCall(RealDamageActor, this, aRawDamage, static_cast(nullptr), false); + 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); @@ -1503,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); @@ -1532,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(); @@ -1560,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 632af0ae83..f0d7a0b597 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,7 +243,7 @@ struct Actor : TESObjectREFR void DispelAllSpells(bool aNow = false) noexcept; void Reset() noexcept; void Kill() noexcept; - void ApplyHitFromOwner(float aRawDamage) 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 0136aa9f83..221dfa7419 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. diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index a8d79b35eb..478f0b2322 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 @@ -343,19 +350,15 @@ 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. A delta for the local player is a hit that the attacking NPC's - // owner saw, see HookDamageActor(). Copies of other players take their owner's health from the actor value sync. - if (pActor->GetExtension()->IsLocalPlayer()) - { - if (acMessage.DeltaHealth < 0.f) - pActor->ApplyHitFromOwner(-acMessage.DeltaHealth); - return; - } - if (pActor->GetExtension()->IsRemotePlayer()) + // 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; @@ -466,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 0000000000..a4dd25b0de --- /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 0000000000..b3a51ed5f1 --- /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/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index 1c33ad8f3c..80fb3567a0 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 0000000000..2c23dddc68 --- /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 0000000000..9993631a4e --- /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 0000000000..53d65a3f2b --- /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 0000000000..7a9f5df891 --- /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 7022b2deed..434dd0f475 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 ac1cd77679..9fa7d8f983 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 c8f27362c9..e35fc733e9 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 @@ -124,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 882533492d..6a53e7078d 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 032092a2ad..14d94e528b 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;