From 879ac2808264b1abd466924ca4c66601c726b951 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 23:07:40 +0100 Subject: [PATCH 1/3] Lay dead bodies back down when they stand up A corpse can stand because it was rebuilt for a leveled pick or got health back, and saves keep it that way. Keep those from happening to dead actors, and while connected check nearby bodies for a standing skeleton and ragdoll them, logging BodyStand lines. Also log a respawn caused by a save disagreeing with the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Services/CharacterService.h | 1 + .../Services/Generic/ActorValueService.cpp | 4 + .../Services/Generic/CharacterService.cpp | 137 ++++++++++++++++++ Code/client/Systems/RemoteDeathSystem.h | 3 + 4 files changed, 145 insertions(+) diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 35155205c..fba5a30b1 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 9e2850853..a23101121 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 93a3fbfb3..0a850e9e9 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; diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h index 25235388b..50ce963cc 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); From 79dc9056c2c8a811621edfeef4873546747cb346 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 23:17:25 +0100 Subject: [PATCH 2/3] Declare the world position of NiAVObject The standing body check reads it, and on this branch only the padding was declared. Same layout as the name tags use on API-playtest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/NetImmerse/NiAVObject.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h index 9dcbd4fac..ce5b6775d 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); From faa5fff8fbe1c508bcf267c71ded468581fe7d79 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 22:39:07 +0100 Subject: [PATCH 3/3] Stop retrying a refused remote death on every tick RunRemoteUpdates called Kill() on every tick while the owner had an NPC dead and it was alive here. When the game refused the kill, that meant about 100 calls and log lines a second until ownership moved (1300 in 13 seconds in a playtest log). Retry once a second, give up after five tries with one warning, and start over when the body reloads or dies. Co-Authored-By: Claude Opus 5.5 --- Code/client/Components/RemoteComponent.h | 3 ++ .../Services/Generic/CharacterService.cpp | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Code/client/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 74e4188cd..8fe37cdb2 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/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 0a850e9e9..a533b15d2 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -2080,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();