From c43915c9eecb96c61879d4f69c8ffa4d3396d2da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:28:05 +0000 Subject: [PATCH 1/4] Don't block in the crash handler The vectored exception handler held a non-recursive mutex for every exception, so a second fault while it ran deadlocked the thread and every other thread that faulted after it. It also shut down spdlog even when the exception was later handled, after which any log call crashed. Use an atomic flag instead of the mutex and flush the logs instead of shutting spdlog down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/CrashHandler.cpp | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Code/client/CrashHandler.cpp b/Code/client/CrashHandler.cpp index d6c113d97e..092fc64c8a 100644 --- a/Code/client/CrashHandler.cpp +++ b/Code/client/CrashHandler.cpp @@ -2,6 +2,7 @@ #include "CrashHandler.h" #include #include +#include #include #include #include @@ -23,16 +24,12 @@ std::string SerializeTimePoint(const time_point& time, const std::string& format LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) { - static int alreadyCrashed = 0; + static std::atomic_bool s_alreadyCrashed = false; auto retval = EXCEPTION_CONTINUE_SEARCH; - // Serialize - static std::mutex singleThreaded; - const std::lock_guard lock{singleThreaded}; - - // Check for severe, not continuable and not software-originated exception + // Handle only the first access violation. Later or nested ones pass through instead of blocking. if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && - alreadyCrashed++ == 0) + !s_alreadyCrashed.exchange(true)) { spdlog::critical (__FUNCTION__ ": crash occurred!"); @@ -115,13 +112,13 @@ LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) { spdlog::critical(__FUNCTION__ ": UnhandledExceptionFilter() workaround triggered."); - singleThreaded.unlock(); // Might reenter, but is safe at this point. if ((*pCurrentUnhandledExceptionFilter)(pExceptionInfo) == EXCEPTION_CONTINUE_EXECUTION) retval = EXCEPTION_CONTINUE_EXECUTION; - singleThreaded.lock(); } - spdlog::shutdown(); + // The exception may still be handled, and logging after spdlog::shutdown() crashes + if (auto pLogger = spdlog::default_logger()) + pLogger->flush(); } return retval; } From 9f6bd00bdedd2b6f42989a9af6dc59f8699a99d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:39:25 +0000 Subject: [PATCH 2/4] Don't spawn dead actors that are disabled locally When another player's save has an actor enabled and dead but ours has it disabled, the spawn request enabled it and then killed it, which ran its OnDeath scripts again. With quest dragons that replayed quest stages and rewards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/CharacterService.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 7d3ac21af5..e807292d96 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -576,6 +576,13 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) return; } + // Enabling and killing it here would run its death scripts again, e.g. repeating quest stages and rewards + if (acMessage.IsDead && pActor->IsDisabled() && pActor->GetExtension()->Reconciliation != ActorExtension::ReconciliationStage::WaitingForDisable) + { + spdlog::info("Skipped spawn for dead actor {:X} that is disabled locally, server id: {:X}", cActorId, acMessage.ServerId); + return; + } + const auto view = m_world.view(); const auto itor = std::find_if(std::begin(view), std::end(view), [cActorId, view](entt::entity entity) { return view.get(entity).Id == cActorId; }); From 96eff8c7513217084e38e5a6b3919560898f8967 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:55:51 +0000 Subject: [PATCH 3/4] Don't restart completed run once quests from party updates A party member who hadn't finished a run once quest, like Dragon Rising, sent its start and stages to players who already had. That restarted the quest for them and replayed its rewards, such as the Axe of Whiterun and the Fus shout. Ignore starts and stages for run once quests that are already completed locally, as the game itself would. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/QuestService.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Code/client/Services/Generic/QuestService.cpp b/Code/client/Services/Generic/QuestService.cpp index f6a5d39b83..94c644e550 100644 --- a/Code/client/Services/Generic/QuestService.cpp +++ b/Code/client/Services/Generic/QuestService.cpp @@ -517,6 +517,14 @@ void QuestService::OnQuestUpdate(const NotifyQuestUpdate& aUpdate) noexcept return; } + // Like the game, never restart a run once quest that's already done here, which would hand out its rewards again + const bool cIsStartOrStage = aUpdate.Status == NotifyQuestUpdate::Started || aUpdate.Status == NotifyQuestUpdate::StageUpdate; + if (cIsStartOrStage && (pQuest->flags & TESQuest::Flags::RunOnce) && (pQuest->flags & TESQuest::Flags::Completed)) + { + spdlog::info("Remote quest {:X} is a completed run once quest here, ignoring stage {}", formId, aUpdate.Stage); + return; + } + if (aUpdate.Status == NotifyQuestUpdate::StageUpdate && pQuest->IsStageReached(aUpdate.Stage)) { spdlog::info("Remote quest {:X} already reached stage {}, current stage: {}", formId, aUpdate.Stage, pQuest->currentStage); From 6edab7bb80fb96bd3cff32b4473eaf929af17aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:26:16 +0000 Subject: [PATCH 4/4] Don't teleport remote actors into a worldspace the player isn't in When an owner gives up its actors, the server sends their positions to every player. A player in an interior then loaded those exterior cells and moved the actors there, which crashed a QueuedPromoteLargeReferencesTask with a null cell. It also left quest actors like dead dragons in the wrong place in that player's save. Skip the move when the target worldspace isn't the player's; the actor is placed when it spawns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/CharacterService.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index e807292d96..9763e1433d 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -1293,6 +1293,15 @@ void CharacterService::MoveActor(const Actor* apActor, const GameId& acWorldSpac { const uint32_t cWorldSpaceId = m_world.GetModSystem().GetGameId(acWorldSpaceId); TESWorldSpace* const pWorldSpace = Cast(TESForm::GetById(cWorldSpaceId)); + + // Loading a cell outside the player's worldspace can crash the reference promote tasks and strands the actor + // there in our save. It's placed when it spawns instead. + if (pWorldSpace && pWorldSpace != PlayerCharacter::Get()->GetWorldSpace()) + { + spdlog::info(__FUNCTION__ ": skipped moving actor {:X} to worldspace {:X}, the player isn't in it", apActor->formID, cWorldSpaceId); + return; + } + if (pWorldSpace) { GridCellCoords coordinates = GridCellCoords::CalculateGridCellCoords(acPosition);