diff --git a/Code/client/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 74e4188cdf..8fe37cdb2b 100644 --- a/Code/client/Components/RemoteComponent.h +++ b/Code/client/Components/RemoteComponent.h @@ -20,4 +20,7 @@ struct RemoteComponent uint32_t OwnershipEpoch; // Canonical death state survives loading and prevents living animation replay on corpses. bool IsDead{false}; + // Retries of a death the game did not take, see CharacterService::RunRemoteUpdates. + uint8_t KillAttempts{0}; + double NextKillAttempt{0.0}; }; diff --git a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h index 9dcbd4fac9..ce5b6775d6 100644 --- a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h +++ b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h @@ -1,6 +1,7 @@ #pragma once #include +#include struct BSFixedString; @@ -14,7 +15,11 @@ struct NiAVObject : NiObjectNET virtual void sub_29(); virtual NiAVObject* GetByName(BSFixedString& aName); - uint8_t pad30[0x110 - 0x30]; + uint8_t pad30[0xA0 - 0x30]; + // Translation of the world transform (which starts at 0x7C). + NiPoint3 worldTranslate; + uint8_t padAC[0x110 - 0xAC]; }; +static_assert(offsetof(NiAVObject, worldTranslate) == 0xA0); static_assert(sizeof(NiAVObject) == 0x110); diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 35155205cd..fba5a30b11 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -102,6 +102,7 @@ struct CharacterService ActorData BuildActorData(Actor* apActor) const noexcept; void ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickId) const noexcept; void ProcessLeveledConforms() noexcept; + void RunStandingBodyRepairs() noexcept; void RunLocalUpdates() const noexcept; void RunRemoteUpdates() noexcept; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 9e28508537..a231011215 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -296,6 +296,10 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas return; } + // 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; + const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) { diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 93a3fbfb3f..a533b15d2d 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -340,6 +341,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); + RunStandingBodyRepairs(); } void CharacterService::OnConnected(const ConnectedEvent& acConnectedEvent) const noexcept @@ -655,7 +657,10 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) // Let WaitingFor3D kill a new corpse once its loaded body can fall. if (pActor->IsDead() && !acMessage.IsDead) + { + spdlog::warn("Respawning actor {:X} on spawn, it's dead in this save but alive on the server", pActor->formID); pActor->Respawn(); + } spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -1688,7 +1693,10 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no // Recreated copies use the same WaitingFor3D death transition as new spawns. if (pActor->IsDead() && !acMessage.IsDead) + { + spdlog::warn("Respawning actor {:X} on spawn, it's dead in this save but alive on the server", pActor->formID); pActor->Respawn(); + } spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); @@ -1735,6 +1743,13 @@ void CharacterService::ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickI return; } + // Conforming disables and rebuilds the actor, and a corpse can come back from that standing. + if (apActor->IsDead() && m_pendingLeveledConforms.find(apActor->formID) == m_pendingLeveledConforms.end()) + { + spdlog::info("Not rebuilding dead actor {:X} for the owner's leveled pick {:X}", apActor->formID, cPickId); + return; + } + const TESNPC* pLocalPick = apActor->GetLeveledPick(); const uint32_t localPickId = pLocalPick ? pLocalPick->formID : 0; @@ -1754,6 +1769,128 @@ void CharacterService::ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickI m_pendingLeveledConforms[apActor->formID] = cPickId; } +// Dead bodies can end up on their feet, and saves keep them that way. The game has no way to tell: IsDead only reads +// the life state and IsInRagdollState is true for any dead actor, so the skeleton has to show it. +void CharacterService::RunStandingBodyRepairs() noexcept +{ + constexpr auto cLookInterval = std::chrono::seconds(2); + constexpr float cStandingHeight = 50.f; // Pelvis over the feet: about 60 standing, up to 43 lying on a slope. + constexpr float cStillDistance = 3.f; + constexpr float cNearPlayer = 4096.f; + constexpr uint32_t cBodiesPerLook = 24; + constexpr uint32_t cMaxTries = 3; + + struct StandingBody + { + NiPoint3 Pelvis{}; + uint32_t Tries = 0; + }; + static TiltedPhoques::Map s_standingBodies; + static std::chrono::steady_clock::time_point s_nextLook{}; + static uint32_t s_bodiesToSkip = 0; + + if (!m_transport.IsConnected()) + { + s_standingBodies.clear(); + return; + } + + const auto cNow = std::chrono::steady_clock::now(); + if (cNow < s_nextLook) + return; + s_nextLook = cNow + cLookInterval; + + const PlayerCharacter* pPlayer = PlayerCharacter::Get(); + UI* pUI = UI::Get(); + if (!pPlayer || (pUI && pUI->GetMenuOpen(BSFixedString("Loading Menu")))) + return; + + static BSFixedString s_pelvisName("NPC Pelvis [Pelv]"); + static BSFixedString s_leftFootName("NPC L Foot [Lft ]"); + static BSFixedString s_rightFootName("NPC R Foot [Rft ]"); + static BSFixedString s_ragdollEvent("RagdollInstant"); + + uint32_t seen = 0; + uint32_t looked = 0; + for (const auto cEntity : m_world.view(entt::exclude)) + { + Actor* pActor = Cast(TESForm::GetById(m_world.get(cEntity).Id)); + if (!pActor || pActor->GetExtension()->IsPlayer() || !pActor->IsDead() || pActor->IsDisabled()) + continue; + + const float cDeltaX = pActor->position.x - pPlayer->position.x; + const float cDeltaY = pActor->position.y - pPlayer->position.y; + const float cDeltaZ = pActor->position.z - pPlayer->position.z; + if (cDeltaX * cDeltaX + cDeltaY * cDeltaY + cDeltaZ * cDeltaZ > cNearPlayer * cNearPlayer || !RemoteDeathSystem::CanKill(pActor)) + continue; + + if (seen++ < s_bodiesToSkip) + continue; + if (++looked > cBodiesPerLook) + break; + + const auto* pNpc = Cast(pActor->baseForm); + const char* cpName = pNpc && pNpc->fullName.value.AsAscii() ? pNpc->fullName.value.AsAscii() : "?"; + + if (const float cHealth = pActor->GetActorValue(ActorValueInfo::kHealth); cHealth > 0.f) + { + spdlog::info("BodyStand: dead {:X} ({}) had {:.0f} health, taking it", pActor->formID, cpName, cHealth); + pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, 0.f); + } + + // Only skeletons with these bones can be told apart. Creatures without them are left as they are. + NiNode* pRoot = pActor->GetNiNode(); + const NiAVObject* pPelvis = pRoot->GetByName(s_pelvisName); + const NiAVObject* pLeftFoot = pRoot->GetByName(s_leftFootName); + const NiAVObject* pRightFoot = pRoot->GetByName(s_rightFootName); + if (!pPelvis || !pLeftFoot || !pRightFoot) + continue; + + const NiPoint3 cPelvis = pPelvis->worldTranslate; + const float cHeight = cPelvis.z - std::min(pLeftFoot->worldTranslate.z, pRightFoot->worldTranslate.z); + + auto it = s_standingBodies.find(pActor->formID); + if (cHeight < cStandingHeight) + { + if (it != s_standingBodies.end()) + { + spdlog::info("BodyStand: dead {:X} ({}) is lying down, pelvis {:.0f} over its feet", pActor->formID, cpName, cHeight); + s_standingBodies.erase(it); + } + continue; + } + + // It has to stand still between two looks. A body that's moving is falling or being carried. + if (it == s_standingBodies.end()) + { + s_standingBodies[pActor->formID].Pelvis = cPelvis; + continue; + } + + const float cMovedX = cPelvis.x - it.value().Pelvis.x; + const float cMovedY = cPelvis.y - it.value().Pelvis.y; + const float cMovedZ = cPelvis.z - it.value().Pelvis.z; + const bool cIsStill = cMovedX * cMovedX + cMovedY * cMovedY + cMovedZ * cMovedZ < cStillDistance * cStillDistance; + it.value().Pelvis = cPelvis; + if (!cIsStill || it.value().Tries > cMaxTries) + continue; + + if (it.value().Tries == cMaxTries) + { + ++it.value().Tries; + spdlog::warn("BodyStand: dead {:X} ({}) still stands after {} tries, giving up", pActor->formID, cpName, cMaxTries); + continue; + } + + ++it.value().Tries; + const bool cWasSent = pActor->SendAnimationEvent(&s_ragdollEvent); + spdlog::info("BodyStand: dead {:X} ({}) stands, pelvis {:.0f} over its feet, laying it down (try {}, event accepted: {})", pActor->formID, cpName, cHeight, + it.value().Tries, cWasSent); + } + + s_bodiesToSkip = looked > cBodiesPerLook ? seen - 1 : 0; +} + void CharacterService::ProcessLeveledConforms() noexcept { using ReconciliationStage = ActorExtension::ReconciliationStage; @@ -1943,12 +2080,38 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && - (interpolatedEntities.get(entity).IsDead || pActor->IsDead()); + auto& remoteComponent = interpolatedEntities.get(entity); + const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && (remoteComponent.IsDead || pActor->IsDead()); // Keep position synchronization, but do not overwrite a corpse's animation graph. InterpolationSystem::Update(pActor, interpolationComponent, cNow, !cDeadNpc); - if (cDeadNpc && interpolatedEntities.get(entity).IsDead && !m_world.any_of(entity)) - RemoteDeathSystem::Apply(pActor, true); + + if (!cDeadNpc || !remoteComponent.IsDead) + continue; + + // A body that is (re)loading or already dead gets a fresh set of tries the next time it is needed. + if (m_world.any_of(entity) || pActor->IsDead()) + { + remoteComponent.KillAttempts = 0; + continue; + } + + // The owner has this NPC dead but it is alive here. Kill() can be refused (essential actors, a kill + // already queued), and retrying on every tick floods the log and the game, so retry once a second + // and give up after a few tries. + constexpr uint8_t cMaxKillAttempts = 5; + constexpr double cKillRetryMs = 1000.0; + if (remoteComponent.KillAttempts >= cMaxKillAttempts || cNow < remoteComponent.NextKillAttempt || + !RemoteDeathSystem::CanKill(pActor)) + continue; + + ++remoteComponent.KillAttempts; + remoteComponent.NextKillAttempt = cNow + cKillRetryMs; + if (remoteComponent.KillAttempts == 1) + spdlog::info("Applying loaded remote NPC death {:X}", pActor->formID); + else if (remoteComponent.KillAttempts == cMaxKillAttempts) + spdlog::warn("Remote NPC {:X} is still alive after {} kills while its owner has it dead, giving up until its body reloads", + pActor->formID, cMaxKillAttempts); + pActor->Kill(); } auto animatedView = m_world.view(); diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h index 25235388bd..50ce963ccb 100644 --- a/Code/client/Systems/RemoteDeathSystem.h +++ b/Code/client/Systems/RemoteDeathSystem.h @@ -19,7 +19,10 @@ inline void Apply(Actor* apActor, bool aIsDead) noexcept return; if (!aIsDead) + { + spdlog::info("Respawning remote NPC {:X} because its owner has it alive", apActor->formID); apActor->Respawn(); + } else if (CanKill(apActor)) { spdlog::info("Applying loaded remote NPC death {:X}", apActor->formID);