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/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/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/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 7d3ac21af5..93a3fbfb3f 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -67,6 +69,25 @@ #include #include +#include + +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(); +} +} CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) @@ -115,7 +136,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 +160,22 @@ 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 (!aIsLocalOwner) + m_world.get(aEntity).IsDead = acActorData.IsDead; + if (!apActor) return; - apActor->SetActorValues(acActorData.InitialActorValues); + apActor->SetActorValues(acActorData.IsDead && !apActor->GetExtension()->IsPlayer() && !apActor->IsDead() + ? ValuesUntilKilled(acActorData.InitialActorValues) + : acActorData.InitialActorValues); - if (aApplyInventory) + if (aApplyInventory && CanApplyInventory(apActor)) { const Inventory currentInventory = apActor->GetActorInventory(); if (currentInventory.Entries != acActorData.InitialInventory.Entries || currentInventory.CurrentMagicEquipment != acActorData.InitialInventory.CurrentMagicEquipment) @@ -155,8 +186,12 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); - apActor->Kill(); + // 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(); + } } // 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. @@ -284,7 +319,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 +336,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunLocalUpdates(); RunFactionsUpdates(); RunRemoteUpdates(); + ApplyPendingInventories(); RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); @@ -346,7 +382,7 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve pActor->GetExtension()->SetRemote(false); } - m_world.clear(); + m_world.clear(); for (const auto& [formId, pickFormId] : m_pendingLeveledConforms) { @@ -605,7 +641,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) @@ -615,8 +653,9 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) m_world.emplace_or_replace(*entity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + // Let WaitingFor3D kill a new corpse once its loaded body can fall. + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -630,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; @@ -638,6 +677,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 +834,8 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !pActor || !pActor->GetNiNode()) + 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)) @@ -809,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; @@ -858,7 +902,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); @@ -1628,7 +1672,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) @@ -1638,8 +1684,11 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + remoteComponent.IsDead = acMessage.IsDead; + + // Recreated copies use the same WaitingFor3D death transition as new spawns. + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); @@ -1894,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(); @@ -1909,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)); @@ -1939,7 +2003,10 @@ void CharacterService::RunRemoteUpdates() noexcept auto& waitingFor3D = waitingView.get(entity); Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); - if (!pActor || !pActor->GetNiNode()) + 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 @@ -1947,15 +2014,17 @@ void CharacterService::RunRemoteUpdates() noexcept 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(); @@ -2079,6 +2148,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{}; @@ -2095,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/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; 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/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/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); 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/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. 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); } } 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); +}