From 4e02cc23c9d776ca526b693077f72245c80e2c63 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 21:44:16 +0100 Subject: [PATCH 1/6] Sync exterior locks across nearby cells Port the server relay change from Dosylia/urSovngarde 87ea449f. Nearby exterior players in the same worldspace receive lock updates while interior delivery stays cell-scoped. Validation: SkyrimTogetherServer release build passed using a temporary correction of the existing TiltedCore tag to v0.2.9. Two-client cell-boundary testing remains required. --- Code/server/Services/ObjectService.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Code/server/Services/ObjectService.cpp b/Code/server/Services/ObjectService.cpp index 2bd7d154f4..d43ab85b48 100644 --- a/Code/server/Services/ObjectService.cpp +++ b/Code/server/Services/ObjectService.cpp @@ -162,7 +162,11 @@ void ObjectService::OnLockChange(const PacketEvent& acMessage if (pPlayer == acMessage.pPlayer) continue; - if (pPlayer->GetCellComponent().Cell == acMessage.Packet.CellId) + const auto& senderCell = acMessage.pPlayer->GetCellComponent(); + const auto& recipientCell = pPlayer->GetCellComponent(); + // Exterior references can be loaded by players in neighboring cells. + const bool cNearbyExterior = !senderCell.IsInInteriorCell() && !recipientCell.IsInInteriorCell() && senderCell.IsInRange(recipientCell, false); + if (recipientCell.Cell == acMessage.Packet.CellId || cNearbyExterior) pPlayer->Send(notifyLockChange); } } From 6e47ad68f9590912abd04206f721c1d263d6a31c Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:22 +0100 Subject: [PATCH 2/6] Stop sending truncated client messages Adapt urSovngarde 347ce181 and local 35717e8d. Track failed buffer growth explicitly, retry outside the scratch allocator up to the transport's 512 KiB limit, and preserve packet flags. Validation: client release build and TPTests passed (76 assertions, 8 cases), including exact-size limits, failed writes followed by successful writes, and a 2000-plugin authentication round trip. --- .../Services/Generic/TransportService.cpp | 34 ++++++---- Code/client/Services/SendBuffer.h | 64 +++++++++++++++++++ Code/tests/send_buffer.cpp | 63 ++++++++++++++++++ 3 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 Code/client/Services/SendBuffer.h create mode 100644 Code/tests/send_buffer.cpp diff --git a/Code/client/Services/Generic/TransportService.cpp b/Code/client/Services/Generic/TransportService.cpp index ed490d1502..61de8cbf23 100644 --- a/Code/client/Services/Generic/TransportService.cpp +++ b/Code/client/Services/Generic/TransportService.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -75,23 +76,30 @@ bool TransportService::Send(const ClientMessage& acMessage, const TiltedPhoques: ~ScopedReset() { s_allocator.Reset(); } } allocatorGuard; - if (IsConnected()) - { - ScopedAllocator _{s_allocator}; - - Buffer buffer(1 << 16); - Buffer::Writer writer(&buffer); - writer.WriteBits(0, 8); // Write first byte as packet needs it - - acMessage.Serialize(writer); - TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), writer.Size()); + if (!IsConnected()) + return false; - Client::Send(&packet, aPacketFlags); + { + SendBuffer::PacketBuffer buffer(acMessage, s_allocator, 1 << 16); + if (buffer.IsComplete()) + { + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); + Client::Send(&packet, aPacketFlags); + return true; + } + } - return true; + SendBuffer::PacketBuffer buffer(acMessage, *Allocator::GetDefault(), k_cbMaxSteamNetworkingSocketsMessageSizeSend); + if (!buffer.IsComplete()) + { + spdlog::error("Message with opcode {} could not be serialized within the {} byte send limit", static_cast(acMessage.GetOpcode()), + k_cbMaxSteamNetworkingSocketsMessageSizeSend); + return false; } - return false; + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); + Client::Send(&packet, aPacketFlags); + return true; } void TransportService::OnConsume(const void* apData, uint32_t aSize) diff --git a/Code/client/Services/SendBuffer.h b/Code/client/Services/SendBuffer.h new file mode 100644 index 0000000000..73c9463c16 --- /dev/null +++ b/Code/client/Services/SendBuffer.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +namespace SendBuffer +{ +// The writer does not retain failed-write state. Remember every rejected growth +// attempt, including a large write followed by smaller writes that still fit. +class LimitedAllocator final : public TiltedPhoques::Allocator +{ +public: + LimitedAllocator(TiltedPhoques::Allocator& aAllocator, size_t aCapacity) noexcept + : m_allocator(aAllocator), m_capacity(aCapacity) + { + } + + void* Allocate(size_t aSize) noexcept override + { + if (aSize > m_capacity) + { + m_failed = true; + return nullptr; + } + void* pData = m_allocator.Allocate(aSize); + m_failed |= pData == nullptr; + return pData; + } + void Free(void* apData) noexcept override { m_allocator.Free(apData); } + size_t Size(void* apData) noexcept override { return m_allocator.Size(apData); } + bool Failed() const noexcept { return m_failed; } + +private: + TiltedPhoques::Allocator& m_allocator; + size_t m_capacity; + bool m_failed = false; +}; + +class PacketBuffer +{ +public: + PacketBuffer(const ClientMessage& acMessage, TiltedPhoques::Allocator& aAllocator, size_t aCapacity) noexcept + : m_allocator(aAllocator, aCapacity), m_writer(&m_buffer) + { + m_buffer.SetAllocator(&m_allocator); + if (!m_buffer.Resize(aCapacity)) + return; + m_writer.WriteBits(0, 8); // Packet layer header. + acMessage.Serialize(m_writer); + } + + TP_NOCOPYMOVE(PacketBuffer); + bool IsComplete() const noexcept { return !m_allocator.Failed(); } + uint8_t* GetData() const noexcept { return m_buffer.GetWriteData(); } + size_t Size() const noexcept { return m_writer.Size(); } + +private: + LimitedAllocator m_allocator; + TiltedPhoques::Buffer m_buffer; + TiltedPhoques::Buffer::Writer m_writer; +}; +} diff --git a/Code/tests/send_buffer.cpp b/Code/tests/send_buffer.cpp new file mode 100644 index 0000000000..7cc7cc6230 --- /dev/null +++ b/Code/tests/send_buffer.cpp @@ -0,0 +1,63 @@ +#include "../client/Services/SendBuffer.h" +#include +#include +#include +#include + +namespace +{ +struct PayloadMessage final : ClientMessage +{ + explicit PayloadMessage(size_t aSize) : ClientMessage(kAuthenticationRequest), Data(aSize, 0x5A) {} + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override + { + aWriter.WriteBytes(Data.data(), Data.size()); + aWriter.WriteBits(0xAB, 8); // A successful write after a failed large write must not hide the failure. + } + std::vector Data; +}; +} + +TEST_CASE("Client send buffer rejects partial messages and accepts exact fits", "[encoding.send]") +{ + auto& allocator = *TiltedPhoques::Allocator::GetDefault(); + constexpr size_t kCapacity = 512 * 1024; + const PayloadMessage empty(0); + const SendBuffer::PacketBuffer overhead(empty, allocator, 64); + REQUIRE(overhead.IsComplete()); + + const PayloadMessage exact(kCapacity - overhead.Size()); + const SendBuffer::PacketBuffer full(exact, allocator, kCapacity); + REQUIRE(full.IsComplete()); + REQUIRE(full.Size() == kCapacity); + REQUIRE(full.GetData()[kCapacity - 1] == 0xAB); + + const PayloadMessage oversized(kCapacity); + const SendBuffer::PacketBuffer rejected(oversized, allocator, kCapacity); + REQUIRE_FALSE(rejected.IsComplete()); + REQUIRE(rejected.Size() < kCapacity); +} + +TEST_CASE("Large authentication retries after the scratch buffer overflows", "[encoding.send]") +{ + AuthenticationRequest request; + for (uint16_t i = 0; i < 2000; ++i) + request.UserMods.ModList.push_back({TiltedPhoques::String(40, 'x') + ".esp", i, true}); + TiltedPhoques::ScratchAllocator scratch(1 << 18); + const SendBuffer::PacketBuffer small(request, scratch, 1 << 16); + REQUIRE_FALSE(small.IsComplete()); + + const SendBuffer::PacketBuffer large(request, *TiltedPhoques::Allocator::GetDefault(), 512 * 1024); + REQUIRE(large.IsComplete()); + TiltedPhoques::Buffer copy(large.GetData(), large.Size()); + TiltedPhoques::Buffer::Reader reader(©); + uint64_t header = 1; + REQUIRE(reader.ReadBits(header, 8)); + REQUIRE(header == 0); + uint64_t opcode = 0; + REQUIRE(reader.ReadBits(opcode, 8)); + REQUIRE(opcode == kAuthenticationRequest); + AuthenticationRequest decoded; + decoded.DeserializeRaw(reader); + REQUIRE(decoded == request); +} From 3c6d94ebba1b4117e2606c4d63e093607898eade Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:22 +0100 Subject: [PATCH 3/6] Retry Papyrus natives that were unavailable at startup Port urSovngarde 9342bca4 lookup hardening. Synchronize our registry, cache only successful lookups, and skip missing calls with one diagnostic. Missing values are false, null or zero; void calls are not replayed. Ghidra MCP: SE 1.6.1170 registration ID 104788 at 0x141445FE0 locks the VM registration table. Our queued registry access is source-verified. Native ABI, positive function-pointer lifetime and 1.7.104 equivalence remain assumed; hooks are unchanged. Client release build passed; delayed-registration gameplay testing remains required. --- .../Services/Generic/PapyrusService.cpp | 33 ++++++++ Code/client/Services/PapyrusService.h | 82 +++++++++++++++---- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/Code/client/Services/Generic/PapyrusService.cpp b/Code/client/Services/Generic/PapyrusService.cpp index 6c644d540e..6c66a956d4 100644 --- a/Code/client/Services/Generic/PapyrusService.cpp +++ b/Code/client/Services/Generic/PapyrusService.cpp @@ -4,6 +4,8 @@ #include +#include + PapyrusService::PapyrusService(entt::dispatcher& aDispatcher) noexcept { m_papyrusFunctionRegisterConnection = aDispatcher.sink().connect<&PapyrusService::HandlePapyrusFunctionEvent>(this); @@ -11,6 +13,7 @@ PapyrusService::PapyrusService(entt::dispatcher& aDispatcher) noexcept const void* PapyrusService::Get(const String& acNamespace, const String& acFunction) const noexcept { + std::shared_lock lock(m_lock); const auto itor = m_functions.find(acNamespace + "::" + acFunction); if (itor != std::end(m_functions)) return itor->second; @@ -18,7 +21,37 @@ const void* PapyrusService::Get(const String& acNamespace, const String& acFunct return nullptr; } +const void* PapyrusLookup::Resolve() const noexcept +{ + if (const void* pFunction = m_pFunction.load(std::memory_order_acquire)) + return pFunction; + + const auto& papyrus = World::Get().ctx().at(); + const void* pFunction = papyrus.Get(m_pScope, m_pName); + if (!pFunction) + { + if (!m_reported.test_and_set()) + spdlog::error("Papyrus: {}.{} not found among the {} functions recorded; not called until it is", m_pScope, m_pName, papyrus.FunctionCount()); + return nullptr; + } + + m_pFunction.store(pFunction, std::memory_order_release); + + // Once, so every log says how many natives were recorded by the time they were first used. + static std::atomic_flag s_counted = ATOMIC_FLAG_INIT; + if (!s_counted.test_and_set()) + spdlog::info("Papyrus: {} native functions recorded at the first call ({}.{})", papyrus.FunctionCount(), m_pScope, m_pName); + return pFunction; +} + +size_t PapyrusService::FunctionCount() const noexcept +{ + std::shared_lock lock(m_lock); + return m_functions.size(); +} + void PapyrusService::HandlePapyrusFunctionEvent(const PapyrusFunctionRegisterEvent& acEvent) noexcept { + std::unique_lock lock(m_lock); m_functions[acEvent.Namespace + "::" + acEvent.Name] = acEvent.Function; } diff --git a/Code/client/Services/PapyrusService.h b/Code/client/Services/PapyrusService.h index 13737da5c7..79366cd7a2 100644 --- a/Code/client/Services/PapyrusService.h +++ b/Code/client/Services/PapyrusService.h @@ -2,6 +2,11 @@ #include +#include +#include +#include +#include + struct TESForm; struct TESObjectREFR; struct PapyrusFunctionRegisterEvent; @@ -17,43 +22,86 @@ struct PapyrusService TP_NOCOPYMOVE(PapyrusService); const void* Get(const String& acNamespace, const String& acFunction) const noexcept; + size_t FunctionCount() const noexcept; void HandlePapyrusFunctionEvent(const PapyrusFunctionRegisterEvent&) noexcept; private: + // Written on our update as the game registers its natives, read from any thread that calls one. + mutable std::shared_mutex m_lock; Map m_functions; entt::scoped_connection m_papyrusFunctionRegisterConnection; }; +//! Retry missing natives instead of permanently caching a null startup lookup. +class PapyrusLookup +{ +public: + PapyrusLookup(const char* acpScope, const char* acpName) noexcept + : m_pScope(acpScope) + , m_pName(acpName) + { + } + + //! The function, or null while it is not recorded. + const void* Resolve() const noexcept; + +private: + const char* m_pScope; + const char* m_pName; + mutable std::atomic m_pFunction{nullptr}; + mutable std::atomic_flag m_reported = ATOMIC_FLAG_INIT; +}; + +// Missing predicates return false, pointers return null, and GetOpenState returns kNone. +// Void operations are skipped; they are not queued for replay. +template Return MissingPapyrusResult() noexcept +{ + if constexpr (!std::is_void_v) + return Return{}; +} + template struct PapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, uint32_t, const Type*, Args...); - PapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + PapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } - Return operator()(const Type* apThis, Args... args) const noexcept { return m_pFunction(GameVM::Get()->virtualMachine, 0, apThis, std::forward(args)...); } + Return operator()(const Type* apThis, Args... args) const noexcept + { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + return pFunction(GameVM::Get()->virtualMachine, 0, apThis, std::forward(args)...); + } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; template struct GlobalPapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, Args...); - GlobalPapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + GlobalPapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } - Return operator()(Args... args) const noexcept { return m_pFunction(GameVM::Get()->virtualMachine, std::forward(args)...); } + Return operator()(Args... args) const noexcept + { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + return pFunction(GameVM::Get()->virtualMachine, std::forward(args)...); + } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; struct RefrOrInventoryObj @@ -67,22 +115,26 @@ template struct LatentPapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, uint32_t, const RefrOrInventoryObj&, Args...); - LatentPapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + LatentPapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } Return operator()(const Type* apThis, Args... args) const noexcept { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + RefrOrInventoryObj self{apThis, nullptr, 0}; - return m_pFunction(GameVM::Get()->virtualMachine, 0, self, std::forward(args)...); + return pFunction(GameVM::Get()->virtualMachine, 0, self, std::forward(args)...); } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; -#define PAPYRUS_FUNCTION(returnType, scope, name, ...) static PapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); -#define GLOBAL_PAPYRUS_FUNCTION(returnType, scope, name, ...) static GlobalPapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); -#define LATENT_PAPYRUS_FUNCTION(returnType, scope, name, ...) static LatentPapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); +#define PAPYRUS_FUNCTION(returnType, scope, name, ...) static PapyrusFunction s_p##name(#scope, #name); +#define GLOBAL_PAPYRUS_FUNCTION(returnType, scope, name, ...) static GlobalPapyrusFunction s_p##name(#scope, #name); +#define LATENT_PAPYRUS_FUNCTION(returnType, scope, name, ...) static LatentPapyrusFunction s_p##name(#scope, #name); From c122f79a7f93342b973029c0879920f548d2ad33 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:23 +0100 Subject: [PATCH 4/6] Defer actor inventories until their process is loaded Adapt the inventory portion of urSovngarde 7712c3e6. Store pending snapshots on entities with ownership epochs, preserve subsequent inventory and equipment updates in order, and clear deferred state on removal or disconnect. Require process and 3D readiness for spawn and ownership grants. Ghidra MCP, SE 1.6.1170: equip ID 38894 at 0x1406C9820 reaches 0x1406CBE30, reading Actor+0xF8; spell equip ID 38896 reaches 0x1406CBAD0 with the same process access. CommonLib places middleHigh at AIProcess+0x8, now asserted. This is a conservative readiness gate; the exact fork crash, all engine threading guarantees and 1.7.104 equivalence remain unproven. Client release build passed; two-client loading, loot, transfer and disconnect tests remain required. --- Code/client/Components.h | 1 + .../Components/PendingInventoryComponent.h | 22 +++++++ Code/client/Games/Skyrim/AI/AIProcess.h | 1 + Code/client/Services/CharacterService.h | 1 + .../Services/Generic/CharacterService.cpp | 58 ++++++++++++++++--- .../Services/Generic/InventoryService.cpp | 15 +++++ 6 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 Code/client/Components/PendingInventoryComponent.h diff --git a/Code/client/Components.h b/Code/client/Components.h index 0de09050e3..ddbb1dfbce 100644 --- a/Code/client/Components.h +++ b/Code/client/Components.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include diff --git a/Code/client/Components/PendingInventoryComponent.h b/Code/client/Components/PendingInventoryComponent.h new file mode 100644 index 0000000000..e47819629d --- /dev/null +++ b/Code/client/Components/PendingInventoryComponent.h @@ -0,0 +1,22 @@ +#pragma once + +#ifndef TP_INTERNAL_COMPONENTS_GUARD +#error Include Components.h instead +#endif + +#include +#include +#include +#include + +struct PendingInventoryComponent +{ + PendingInventoryComponent(const Inventory& acInventory, uint32_t aOwnershipEpoch) + : Content(acInventory), OwnershipEpoch(aOwnershipEpoch) + { + } + + Inventory Content; + uint32_t OwnershipEpoch; + Vector> Updates; +}; diff --git a/Code/client/Games/Skyrim/AI/AIProcess.h b/Code/client/Games/Skyrim/AI/AIProcess.h index d08fb69ec5..35ffa2517b 100644 --- a/Code/client/Games/Skyrim/AI/AIProcess.h +++ b/Code/client/Games/Skyrim/AI/AIProcess.h @@ -36,3 +36,4 @@ struct HighProcessData }; static_assert(offsetof(AIProcess, movementType) == 0x137); +static_assert(offsetof(AIProcess, middleProcess) == 0x8); diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 031d4e24ce..35155205cd 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -109,6 +109,7 @@ struct CharacterService void RunSpawnUpdates() const noexcept; void RunExperienceUpdates() noexcept; void ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) noexcept; + void ApplyPendingInventories() noexcept; World& m_world; entt::dispatcher& m_dispatcher; diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 7d3ac21af5..0d629cb813 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -67,6 +67,15 @@ #include #include +#include + +namespace +{ +bool CanApplyInventory(Actor* apActor) noexcept +{ + return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); +} +} CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) @@ -115,7 +124,7 @@ CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, void CharacterService::DeleteRemoteEntityComponents(entt::entity aEntity) const noexcept { - m_world.remove(aEntity); + m_world.remove(aEntity); } void CharacterService::DeclineOwnership(const uint32_t aServerId, const uint32_t aOwnershipEpoch) const noexcept @@ -139,12 +148,17 @@ void CharacterService::ReconcileActorData( pWaitingFor3D->SpawnRequest.OwnershipEpoch = aOwnershipEpoch; } + // A new canonical snapshot supersedes deferred data from the previous owner. + m_world.remove(aEntity); + if (aApplyInventory && !CanApplyInventory(apActor)) + m_world.emplace(aEntity, acActorData.InitialInventory, aOwnershipEpoch); + if (!apActor) return; apActor->SetActorValues(acActorData.InitialActorValues); - if (aApplyInventory) + if (aApplyInventory && CanApplyInventory(apActor)) { const Inventory currentInventory = apActor->GetActorInventory(); if (currentInventory.Entries != acActorData.InitialInventory.Entries || currentInventory.CurrentMagicEquipment != acActorData.InitialInventory.CurrentMagicEquipment) @@ -284,7 +298,7 @@ void CharacterService::OnActorRemoved(const ActorRemovedEvent& acEvent) noexcept auto& formIdComponent = view.get(cId); CancelServerAssignment(*entityIt, formIdComponent.Id); - m_world.remove(cId); + m_world.remove(cId); if (m_world.all_of(cId)) m_world.remove(cId); @@ -301,6 +315,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunLocalUpdates(); RunFactionsUpdates(); RunRemoteUpdates(); + ApplyPendingInventories(); RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); @@ -346,7 +361,7 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve pActor->GetExtension()->SetRemote(false); } - m_world.clear(); + m_world.clear(); for (const auto& [formId, pickFormId] : m_pendingLeveledConforms) { @@ -638,6 +653,7 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) AnimationSystem::Setup(m_world, *entity); m_world.emplace_or_replace(*entity, acMessage); + m_world.emplace_or_replace(*entity, acMessage.InventoryContent, acMessage.OwnershipEpoch); auto& remoteAnimationComponent = m_world.get(*entity); @@ -794,7 +810,7 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !pActor || !pActor->GetNiNode()) + if (!pFormIdComponent || !CanApplyInventory(pActor)) { uint32_t cachedRefId = pFormIdComponent ? pFormIdComponent->Id : 0; if (const auto* pRemoteComponent = m_world.try_get(cEntity)) @@ -858,7 +874,7 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess pRemoteComponent->OwnershipEpoch = acMessage.OwnershipEpoch; } - ReconcileActorData(cEntity, pActor, acMessage.OwnershipEpoch, acMessage.CurrentActorData, pActor && pActor->GetNiNode(), false); + ReconcileActorData(cEntity, pActor, acMessage.OwnershipEpoch, acMessage.CurrentActorData, true, false); if (pActor) ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); @@ -1939,7 +1955,7 @@ void CharacterService::RunRemoteUpdates() noexcept auto& waitingFor3D = waitingView.get(entity); Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); - if (!pActor || !pActor->GetNiNode()) + if (!CanApplyInventory(pActor)) continue; // By now, the actor has materialized in the world and is ready for further setup @@ -2079,6 +2095,34 @@ void CharacterService::RunExperienceUpdates() noexcept spdlog::debug("Sending over experience {}", message.Experience); } +void CharacterService::ApplyPendingInventories() noexcept +{ + auto view = m_world.view(entt::exclude); + for (auto entity : view) + { + const auto& remote = view.get(entity); + auto& pending = view.get(entity); + if (remote.OwnershipEpoch != pending.OwnershipEpoch) + { + m_world.remove(entity); + continue; + } + + Actor* pActor = Cast(TESForm::GetById(view.get(entity).Id)); + if (!CanApplyInventory(pActor)) + continue; + + const Inventory current = pActor->GetActorInventory(); + if (current.Entries != pending.Content.Entries || current.CurrentMagicEquipment != pending.Content.CurrentMagicEquipment) + pActor->SetActorInventory(pending.Content); + + auto updates = std::move(pending.Updates); + m_world.remove(entity); + for (const auto& update : updates) + std::visit([this](const auto& acMessage) { m_dispatcher.trigger(acMessage); }, update); + } +} + void CharacterService::ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) noexcept { std::vector toRemove{}; diff --git a/Code/client/Services/Generic/InventoryService.cpp b/Code/client/Services/Generic/InventoryService.cpp index 8d9f2c78ca..14f5a89ac9 100644 --- a/Code/client/Services/Generic/InventoryService.cpp +++ b/Code/client/Services/Generic/InventoryService.cpp @@ -159,7 +159,15 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac }); if (remoteIt != remoteView.end()) + { + if (auto* pPending = m_world.try_get(*remoteIt); + pPending && pPending->OwnershipEpoch == acMessage.OwnershipEpoch) + { + pPending->Updates.emplace_back(acMessage); + return; + } pActor = Cast(TESForm::GetById(remoteView.get(*remoteIt).Id)); + } else { auto localView = m_world.view(); @@ -211,6 +219,13 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac return; } + if (auto* pPending = m_world.try_get(*it); + pPending && pPending->OwnershipEpoch == acMessage.OwnershipEpoch) + { + pPending->Updates.emplace_back(acMessage); + return; + } + Actor* pActor = Cast(TESForm::GetById(view.get(*it).Id)); if (!pActor) return; From 514a0ab2666726a5cd0177043a0eb9ba784d937f Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 23:30:35 +0100 Subject: [PATCH 5/6] fix/CharacterService Dead NPC stands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I found the relevant fix in 34fe6f91, plus a correction in 83be966c. The problem is timing: applying zero health before an NPC’s body loads can mark it dead before it gets a chance to fall over. The fix keeps a newly arriving copy briefly alive until its body is ready, then applies the death. The correction prevents doing that to an NPC that is already dead locally. I’m checked the Skyrim 1.6.1170 death routine before adapting those changes to our existing loading flow. Used Ghidra integration, ran commands In the 1.6.1170 binary, the death routine at 0x1406972D0 (Address Library ID 37896) has an early-return state check and separate handling when the actor’s process or loaded body is missing. That supports deferring the death until loading completes, though the visible result still needs an in-game test. I ported the health and loading guards, including the “already dead locally” correction. The fork’s VR-specific corpse repair code isn’t needed for this port. The port is in place across new spawns, recreated actors, and reconciliation. Already-dead NPCs retain their original health values. The client build has compiled the changed service without errors and is continuing through the remaining files. This port uses the existing WaitingFor3D step and adds no hooks or offsets. The key in-game check is to kill an NPC, have a second player enter the cell, then leave and re-enter. The corpse should lie down correctly on each client, while bodies already dead locally should stay untouched. The client release build passed. I’ll restore the temporary build-file change, commit this as the fifth reliability fix, and push it to the same fork branch. --- .../Services/Generic/CharacterService.cpp | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 0d629cb813..396fa7d81f 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -71,6 +72,16 @@ namespace { +ActorValues ValuesUntilKilled(const ActorValues& acValues) noexcept +{ + // Zero health can kill a newly loaded copy before its body is ready to fall. + // Only use this for a living NPC that will be killed after loading its 3D. + ActorValues values = acValues; + if (auto it = values.ActorValuesList.find(ActorValueInfo::kHealth); it != values.ActorValuesList.end() && it->second <= 0.f) + it.value() = 1.f; + return values; +} + bool CanApplyInventory(Actor* apActor) noexcept { return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); @@ -156,7 +167,9 @@ void CharacterService::ReconcileActorData( if (!apActor) return; - apActor->SetActorValues(acActorData.InitialActorValues); + apActor->SetActorValues(acActorData.IsDead && !apActor->GetExtension()->IsPlayer() && !apActor->IsDead() + ? ValuesUntilKilled(acActorData.InitialActorValues) + : acActorData.InitialActorValues); if (aApplyInventory && CanApplyInventory(apActor)) { @@ -169,8 +182,12 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); - apActor->Kill(); + // Remote spawns finish their death transition in the WaitingFor3D step. + if (aIsLocalOwner || !m_world.any_of(aEntity)) + { + spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); + apActor->Kill(); + } } // Respawn resets the reference, which re-rolls leveled actors and reloads their 3D. The owner would // rediscover it as a new actor, so keep the local death and let the death state sync report it. @@ -620,7 +637,9 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) pActor->rotation.x = acMessage.Rotation.x; pActor->rotation.z = acMessage.Rotation.y; pActor->MoveTo(PlayerCharacter::Get()->parentCell, acMessage.Position); - pActor->SetActorValues(acMessage.InitialActorValues); + pActor->SetActorValues(acMessage.IsDead && !acMessage.IsPlayer && !pActor->IsDead() + ? ValuesUntilKilled(acMessage.InitialActorValues) + : acMessage.InitialActorValues); pActor->GetExtension()->SetPlayer(acMessage.IsPlayer); if (acMessage.IsPlayer) @@ -630,7 +649,8 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) m_world.emplace_or_replace(*entity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) + // Let WaitingFor3D kill a new corpse once its loaded body can fall. + if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -1644,7 +1664,9 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no pActor->rotation.x = acMessage.Rotation.x; pActor->rotation.z = acMessage.Rotation.y; pActor->MoveTo(PlayerCharacter::Get()->parentCell, pInterpolationComponent->Position); - pActor->SetActorValues(acMessage.InitialActorValues); + pActor->SetActorValues(acMessage.IsDead && !acMessage.IsPlayer && !pActor->IsDead() + ? ValuesUntilKilled(acMessage.InitialActorValues) + : acMessage.InitialActorValues); pActor->GetExtension()->SetPlayer(acMessage.IsPlayer); if (acMessage.IsPlayer) @@ -1654,7 +1676,8 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) + // Recreated copies use the same WaitingFor3D death transition as new spawns. + if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); From 7560b464c411a202fbc52a0e8da30be6a7a5459f Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 22:43:25 +0100 Subject: [PATCH 6/6] Keep remote corpses dead through loading and animation replay Retain canonical death state across unloaded actors and defer NPC death until its high process and body are ready. Suppress stale living animation writes on corpses while retaining position synchronization, and update pending spawns from death notifications. Verified KillImpl readiness branches in Skyrim 1.6.1170 via read-only Ghidra. Client releasedbg build passes; multiplayer regression checks and 1.7.104 binary equivalence remain unverified. See RemoteDeathSystem.md. --- Code/client/Components/RemoteComponent.h | 2 + .../Services/Generic/ActorValueService.cpp | 34 ++++++++--- .../Services/Generic/CharacterService.cpp | 59 ++++++++++++++----- Code/client/Systems/InterpolationSystem.cpp | 5 +- Code/client/Systems/InterpolationSystem.h | 2 +- Code/client/Systems/RemoteDeathSystem.h | 29 +++++++++ Code/client/Systems/RemoteDeathSystem.md | 48 +++++++++++++++ 7 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 Code/client/Systems/RemoteDeathSystem.h create mode 100644 Code/client/Systems/RemoteDeathSystem.md diff --git a/Code/client/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 9704cca749..74e4188cdf 100644 --- a/Code/client/Components/RemoteComponent.h +++ b/Code/client/Components/RemoteComponent.h @@ -18,4 +18,6 @@ struct RemoteComponent uint32_t Id; uint32_t CachedRefId; uint32_t OwnershipEpoch; + // Canonical death state survives loading and prevents living animation replay on corpses. + bool IsDead{false}; }; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index b3f309a7ba..9e28508537 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -295,6 +297,21 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas } const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; + if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) + { + const auto entity = Utils::FindEntityByServerId(acMessage.Id); + if (entity && m_world.all_of(*entity)) + { + m_world.get(*entity).IsDead = true; + if (auto* pWaiting = m_world.try_get(*entity)) + pWaiting->SpawnRequest.IsDead = true; + // Do not let zero health commit death before the physical body is ready. + pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, 1.f); + if (!m_world.any_of(*entity)) + RemoteDeathSystem::Apply(pActor, true); + return; + } + } pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, newHealth); const float health = pActor->GetActorValue(ActorValueInfo::kHealth); @@ -381,7 +398,7 @@ void ActorValueService::OnActorMaxValueChanges(const NotifyActorMaxValueChanges& void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessage) const noexcept { - auto view = m_world.view(); + auto view = m_world.view(); const auto it = std::find_if(std::begin(view), std::end(view), [&acMessage, view](entt::entity entity) { @@ -398,8 +415,12 @@ void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessa return; } - auto& formIdComponent = view.get(*it); - Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); + view.get(*it).IsDead = acMessage.IsDead; + if (auto* pWaiting = m_world.try_get(*it)) + pWaiting->SpawnRequest.IsDead = acMessage.IsDead; + + const auto* pFormId = m_world.try_get(*it); + Actor* pActor = pFormId ? Cast(TESForm::GetById(pFormId->Id)) : nullptr; if (!pActor) return; @@ -409,9 +430,6 @@ void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessa if (pExtension->IsPlayer()) return; - if (pActor->IsDead() != acMessage.IsDead) - { - spdlog::info("Applying death state {} to remote actor {:X}, server id: {:X}", acMessage.IsDead, pActor->formID, acMessage.Id); - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); - } + if (!m_world.any_of(*it)) + RemoteDeathSystem::Apply(pActor, acMessage.IsDead); } diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 396fa7d81f..93a3fbfb3f 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -164,6 +165,9 @@ void CharacterService::ReconcileActorData( if (aApplyInventory && !CanApplyInventory(apActor)) m_world.emplace(aEntity, acActorData.InitialInventory, aOwnershipEpoch); + if (!aIsLocalOwner) + m_world.get(aEntity).IsDead = acActorData.IsDead; + if (!apActor) return; @@ -182,8 +186,8 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - // Remote spawns finish their death transition in the WaitingFor3D step. - if (aIsLocalOwner || !m_world.any_of(aEntity)) + // Remote actors finish death in RunRemoteUpdates after placement and 3D setup. + if (aIsLocalOwner) { spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); apActor->Kill(); @@ -650,8 +654,8 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) } // Let WaitingFor3D kill a new corpse once its loaded body can fall. - if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -665,7 +669,7 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) if (acMessage.FormId != GameId{}) ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); - m_world.emplace_or_replace(*entity, acMessage.ServerId, pActor->formID, acMessage.OwnershipEpoch); + m_world.emplace_or_replace(*entity, acMessage.ServerId, pActor->formID, acMessage.OwnershipEpoch).IsDead = acMessage.IsDead; auto& interpolationComponent = InterpolationSystem::Setup(m_world, *entity); interpolationComponent.Position = acMessage.Position; @@ -830,7 +834,8 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !CanApplyInventory(pActor)) + if (!pFormIdComponent || !CanApplyInventory(pActor) || + (acMessage.CurrentActorData.IsDead && !pActor->IsDead() && !RemoteDeathSystem::CanKill(pActor))) { uint32_t cachedRefId = pFormIdComponent ? pFormIdComponent->Id : 0; if (const auto* pRemoteComponent = m_world.try_get(cEntity)) @@ -845,6 +850,9 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess else if (cachedRefId != 0) m_world.emplace(cEntity, acMessage.ServerId, cachedRefId, acMessage.OwnershipEpoch); + if (auto* pRemote = m_world.try_get(cEntity)) + pRemote->IsDead = acMessage.CurrentActorData.IsDead; + spdlog::warn("Declined ownership of actor {:X} at epoch {} because the actor is not ready", acMessage.ServerId, acMessage.OwnershipEpoch); DeclineOwnership(acMessage.ServerId, acMessage.OwnershipEpoch); return; @@ -1676,9 +1684,11 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } + remoteComponent.IsDead = acMessage.IsDead; + // Recreated copies use the same WaitingFor3D death transition as new spawns. - if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); @@ -1933,7 +1943,12 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - InterpolationSystem::Update(pActor, interpolationComponent, cNow); + const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && + (interpolatedEntities.get(entity).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); } auto animatedView = m_world.view(); @@ -1948,6 +1963,16 @@ void CharacterService::RunRemoteUpdates() noexcept if (!pActor) continue; + if (!pActor->GetExtension()->IsPlayer() && + (animatedView.get(entity).IsDead || pActor->IsDead())) + { + // Keep LastProcessedAction for differential decoding, but discard stale living actions. + animationComponent.TimePoints.clear(); + animationComponent.ReplayCount = 0; + animationComponent.ResetAnimationGraphForReplay = false; + continue; + } + // Actions run on the clock the interpolation pass above just advanced, so the body and its animations stay in step const auto cTick = InterpolationSystem::GetPlaybackTick(animatedView.get(entity)); @@ -1980,21 +2005,26 @@ void CharacterService::RunRemoteUpdates() noexcept Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); if (!CanApplyInventory(pActor)) continue; + if (waitingFor3D.SpawnRequest.IsDead && !pActor->GetExtension()->IsPlayer() && + !pActor->IsDead() && !RemoteDeathSystem::CanKill(pActor)) + continue; // By now, the actor has materialized in the world and is ready for further setup pActor->SetActorInventory(waitingFor3D.SpawnRequest.InventoryContent); pActor->SetFactions(waitingFor3D.SpawnRequest.FactionsContent); - if (!waitingFor3D.SpawnRequest.ActionsToReplay.Actions.empty()) + if (!waitingFor3D.SpawnRequest.IsDead && !waitingFor3D.SpawnRequest.ActionsToReplay.Actions.empty()) { pActor->LoadAnimationVariables(waitingFor3D.SpawnRequest.ActionsToReplay.Actions[0].Variables); } - m_weaponDrawUpdates[pActor->formID] = {waitingFor3D.SpawnRequest.IsWeaponDrawn}; + if (!waitingFor3D.SpawnRequest.IsDead) + m_weaponDrawUpdates[pActor->formID] = {waitingFor3D.SpawnRequest.IsWeaponDrawn}; + else + m_weaponDrawUpdates.erase(pActor->formID); - if (pActor->IsDead() != waitingFor3D.SpawnRequest.IsDead) - waitingFor3D.SpawnRequest.IsDead ? pActor->Kill() : pActor->Respawn(); + RemoteDeathSystem::Apply(pActor, waitingFor3D.SpawnRequest.IsDead); if (pActor->IsVampireLord()) pActor->FixVampireLordModel(); @@ -2162,7 +2192,8 @@ void CharacterService::ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) continue; Actor* pActor = Cast(TESForm::GetById(cId)); - if (!pActor || !pActor->GetExtension()->IsRemote()) + if (!pActor || !pActor->GetExtension()->IsRemote() || + (!pActor->GetExtension()->IsPlayer() && pActor->IsDead())) { toRemove.push_back(cId); continue; diff --git a/Code/client/Systems/InterpolationSystem.cpp b/Code/client/Systems/InterpolationSystem.cpp index e5fdb7e30a..9a7f4f7415 100644 --- a/Code/client/Systems/InterpolationSystem.cpp +++ b/Code/client/Systems/InterpolationSystem.cpp @@ -22,7 +22,7 @@ constexpr double cPositionCorrectionTime = 50.0; constexpr size_t cMaxTimePoints = 64; } // namespace -void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const double aNow) noexcept +void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const double aNow, const bool aApplyAnimation) noexcept { auto& clock = aInterpolationComponent.Clock; const double cDeltaTime = clock.Advance(aNow); @@ -97,7 +97,8 @@ void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterp return; apActor->ForcePosition(aInterpolationComponent.Position); - apActor->LoadAnimationVariables(second.Variables); + if (aApplyAnimation) + apActor->LoadAnimationVariables(second.Variables); if (apActor->currentProcess && apActor->currentProcess->middleProcess) { diff --git a/Code/client/Systems/InterpolationSystem.h b/Code/client/Systems/InterpolationSystem.h index 6a44764107..0363671267 100644 --- a/Code/client/Systems/InterpolationSystem.h +++ b/Code/client/Systems/InterpolationSystem.h @@ -9,7 +9,7 @@ struct Actor; struct InterpolationSystem { // aNow and aArrivalTime are on the same local clock, see PlaybackClock - static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, double aNow) noexcept; + static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, double aNow, bool aApplyAnimation = true) noexcept; static void AddPoint(InterpolationComponent& aInterpolationComponent, InterpolationComponent::TimePoint aPoint, double aArrivalTime) noexcept; // The owner tick that remote actions should be played up to, 0 until the first snapshot has arrived static uint64_t GetPlaybackTick(const InterpolationComponent& acInterpolationComponent) noexcept; diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h new file mode 100644 index 0000000000..25235388bd --- /dev/null +++ b/Code/client/Systems/RemoteDeathSystem.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace RemoteDeathSystem +{ +inline bool CanKill(Actor* apActor) noexcept +{ + // SE 1.6.1170 KillImpl (37896) requires a high process and loaded body for its physical death path. + // AIProcess::movementType is the existing name for the process-level byte at 0x137 (high == 0). + return apActor && apActor->currentProcess && apActor->currentProcess->movementType == 0 && + apActor->currentProcess->middleProcess && apActor->GetNiNode() && apActor->animationGraphHolder.IsReady(); +} + +inline void Apply(Actor* apActor, bool aIsDead) noexcept +{ + if (!apActor || apActor->GetExtension()->IsPlayer() || apActor->IsDead() == aIsDead) + return; + + if (!aIsDead) + apActor->Respawn(); + else if (CanKill(apActor)) + { + spdlog::info("Applying loaded remote NPC death {:X}", apActor->formID); + apActor->Kill(); + } +} +} // namespace RemoteDeathSystem diff --git a/Code/client/Systems/RemoteDeathSystem.md b/Code/client/Systems/RemoteDeathSystem.md new file mode 100644 index 0000000000..5a03eca6ca --- /dev/null +++ b/Code/client/Systems/RemoteDeathSystem.md @@ -0,0 +1,48 @@ +# Remote corpse synchronization + +The remote component retains the canonical dead/alive state even without loaded 3D. +Spawning and reconciliation defer NPC death until a high process, middle process, +loaded node and ready animation graph are available. Zero-health broadcasts use the +same path. Death notifications update pending spawns, including those without a form +component. Ownership grants that would kill an unready actor are declined. + +For dead NPCs, movement snapshots still synchronize position, but do not overwrite +the animation graph. Queued actions are discarded without +resetting the differential decoder. Already-dead actors are not resurrected to repair +their pose; this patch prevents premature death and subsequent living animation replay. + +## Binary evidence + +Read-only Ghidra MCP, Skyrim SE 1.6.1170 unpacked database, image base 0x140000000. +Address Library script maps ID 37896 to 0x1406972D0 (KillImpl). +The decompiled routine has an early virtual-state check at vtable +0x4C8 and returns +when that check succeeds. Its physical-body branch checks: + +```c +lVar12 = param_1[0x1f]; +if (((lVar12 == 0) || (*(char *)(lVar12 + 0x137) != 0)) || (lVar10 == 0)) { + FUN_14068e640(param_1); + *(uint *)(param_1 + 0x1d) |= 0x800; +} else { + // loaded-body death processing +} +``` + +`lVar10` comes from 0x1402E5770, which returns the cached loaded node or the pointer +at [actor+0x68]+0x68. The repository already asserts AIProcess::movementType at 0x137; +CommonLibSSE-NG names this byte processLevel and defines high as zero. No offsets or +hooks were added. Requiring a ready animation graph is a conservative source-level +guard; it is not proven sufficient by the decompilation. Skyrim 1.7.104 was not +independently inspected, so equivalence remains assumed. + +## In-game verification still required + +- Player 1 owns an existing dead NPC near Embershard Mine; player 2 approaches and + must see a lying, lootable corpse without idle animations. +- Repeat with player 2 leaving/re-entering, joining late, and receiving ownership. +- Kill a living remote NPC while player 2 is loading its cell. +- Verify a genuine respawn permits normal animation again; player death is unchanged. +- Confirm corpse loot/equipment and ordinary living NPC animations still work. + +Compilation does not establish that these gameplay checks pass. Previously saved +standing corpses may still need a normal game reload; no forced resurrection is used.