From 8c42f5f9455dc8aa14f75eee459fe6e78ea6b04b Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:45:24 +0300 Subject: [PATCH 01/10] Add WanderingActorTrackerSubsystem --- .../SpudRuntimeStoredActorComponent.cpp | 154 +------- Source/SPUD/Private/SpudState.cpp | 68 +++- Source/SPUD/Private/SpudSubsystem.cpp | 24 -- .../WanderingActorTrackerSubsystem.cpp | 341 ++++++++++++++++++ Source/SPUD/Public/ISpudObject.h | 17 + .../Public/SpudRuntimeStoredActorComponent.h | 20 - Source/SPUD/Public/SpudSubsystem.h | 4 - .../Public/WanderingActorTrackerSubsystem.h | 68 ++++ 8 files changed, 500 insertions(+), 196 deletions(-) create mode 100644 Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp create mode 100644 Source/SPUD/Public/WanderingActorTrackerSubsystem.h diff --git a/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp b/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp index d044a03..0e2765c 100644 --- a/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp +++ b/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp @@ -1,10 +1,6 @@ #include "SpudRuntimeStoredActorComponent.h" -#include "SpudSubsystem.h" -#include "WorldPartition/WorldPartition.h" -#include "WorldPartition/WorldPartitionRuntimeCell.h" -#include "WorldPartition/WorldPartitionRuntimeHash.h" -#include "WorldPartition/WorldPartitionSubsystem.h" +#include "WanderingActorTrackerSubsystem.h" DEFINE_LOG_CATEGORY_STATIC(SpudRuntimeStoredActorComponent, All, All); @@ -17,151 +13,15 @@ USpudRuntimeStoredActorComponent::USpudRuntimeStoredActorComponent() void USpudRuntimeStoredActorComponent::BeginPlay() { Super::BeginPlay(); - - if (const auto SpudSubsystem = GetSpudSubsystem(GetWorld())) - { - // Register comp to subsystem tick check. - if (bCanCrossCell) - { - SpudSubsystem->RegisteredRuntimeStoredActorComponents.Add(this); - } - else - { - SpudSubsystem->OnLevelStore.AddDynamic(this, &ThisClass::OnLevelStore); - SpudSubsystem->PostUnloadStreamingLevel.AddDynamic(this, &ThisClass::OnPostUnloadCell); - SpudSubsystem->PreUnloadStreamingLevel.AddDynamic(this, &ThisClass::OnPreUnloadCell); - } - } + + if (UWanderingActorTrackerSubsystem* Tracker = GetWorld()->GetSubsystem()) + Tracker->RegisterActor(GetOwner()); } void USpudRuntimeStoredActorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) { - if (const auto SpudSubsystem = GetSpudSubsystem(GetWorld())) - { - // Unregister comp from subsystem tick check. - if (bCanCrossCell) - { - SpudSubsystem->RegisteredRuntimeStoredActorComponents.Remove(this); - } - else - { - SpudSubsystem->OnLevelStore.RemoveDynamic(this, &ThisClass::OnLevelStore); - SpudSubsystem->PostUnloadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPostUnloadCell); - SpudSubsystem->PreUnloadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPreUnloadCell); - } - } + if (UWanderingActorTrackerSubsystem* Tracker = GetWorld()->GetSubsystem()) + Tracker->UnregisterActor(GetOwner()); Super::EndPlay(EndPlayReason); -} - -void USpudRuntimeStoredActorComponent::UpdateCurrentCell(bool& CellActivated) -{ - const UWorldPartitionRuntimeCell* OutOverlappedCell = nullptr; - GetCurrentOverlappedCell(OutOverlappedCell); - - if (OutOverlappedCell) - { - CellActivated = OutOverlappedCell->GetCurrentState() == EWorldPartitionRuntimeCellState::Activated; - CurrentCellName = USpudState::GetLevelName(OutOverlappedCell); - } -} - -// ReSharper disable once CppMemberFunctionMayBeConst -void USpudRuntimeStoredActorComponent::OnLevelStore(const FString& LevelName) -{ - if (CurrentCellName.IsEmpty() || !IsActive()) - { - return; - } - - if (CurrentCellName == LevelName) - { - // Always store when cell unloaded - const auto Actor = GetOwner(); - UE_LOG(SpudRuntimeStoredActorComponent, Log, TEXT("Storing actor in cell: %s"), *CurrentCellName); - GetSpudSubsystem(GetWorld())->StoreActorByCell(Actor, CurrentCellName); - } -} - -// ReSharper disable once CppMemberFunctionMayBeConst -void USpudRuntimeStoredActorComponent::OnPostUnloadCell(const FName& LevelName) -{ - if (CurrentCellName.IsEmpty() || !IsActive()) - { - return; - } - - if (CurrentCellName == LevelName) - { - DestroyActor(); - } -} - -void USpudRuntimeStoredActorComponent::OnPreUnloadCell(const FName& LevelName) -{ - // Fixed: Only for can't cross cell actor(static actor) and only update when current cell is null. - bool bCellActivated; - UpdateCurrentCell(bCellActivated); -} - -void USpudRuntimeStoredActorComponent::GetCurrentOverlappedCell( - const UWorldPartitionRuntimeCell*& CurrentOverlappedCell) const -{ - const auto WorldPartitionSubsystem = GetWorld()->GetSubsystem(); - if (!WorldPartitionSubsystem) - { - return; - } - - const auto OwnerLocation = GetOwner()->GetActorLocation(); - auto SmallestCellVolume = 0.f; - - const auto ForEachCellFunction = [this, &CurrentOverlappedCell, &OwnerLocation, &SmallestCellVolume](const UWorldPartitionRuntimeCell* Cell) -> bool - { - // for simplicity, assuming actor bounds are small enough that only a single cell needs to be considered - if (const auto CellBounds = Cell->GetCellBounds(); CellBounds.IsInsideXY(OwnerLocation)) - { - // use the smallest cell - if (const auto Volume = CellBounds.GetVolume(); !CurrentOverlappedCell || Volume < SmallestCellVolume) - { - // we dont need this log - //UE_LOG(SpudRuntimeStoredActorComponent, Log, TEXT("GetCurrentOverlappedCell: found cell %s"), *Cell->GetName()); - - SmallestCellVolume = Volume; - CurrentOverlappedCell = Cell; - } - } - return true; - }; - - // ReSharper disable once CppParameterMayBeConstPtrOrRef - auto ForEachWpFunction = [ForEachCellFunction](UWorldPartition* WorldPartition) -> bool - { - if (WorldPartition) - { - WorldPartition->RuntimeHash->ForEachStreamingCells(ForEachCellFunction); - } - - return true; - }; - - WorldPartitionSubsystem->ForEachWorldPartition(ForEachWpFunction); -} - -// ReSharper disable once CppMemberFunctionMayBeConst -void USpudRuntimeStoredActorComponent::DestroyActor() -{ - // If this is pawn, we also destroy its controller (AI) - if (auto Pawn = Cast(GetOwner())) - { - auto Controller = Pawn->GetController(); - if (Controller && Pawn->IsBotControlled()) - { - UE_LOG(SpudRuntimeStoredActorComponent, Log, TEXT("Destroying actor's controller in cell: %s"), *CurrentCellName); - Controller->Destroy(); - } - } - - UE_LOG(SpudRuntimeStoredActorComponent, Log, TEXT("Destroying actor in cell: %s"), *CurrentCellName); - GetOwner()->Destroy(); -} +} \ No newline at end of file diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index a0cc293..cd9d451 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -59,6 +59,12 @@ void USpudState::StoreLevel(ULevel* Level, bool bReleaseAfter, bool bBlocking) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { + // Wandering акторы управляются WanderingActorTrackerSubsystem — пропускаем + if (Actor->Implements()) + { + continue; + } + StoreActor(Actor, LevelData); } } @@ -455,6 +461,7 @@ void USpudState::StoreObjectProperties(UObject* Obj, uint32 PrefixID, TArrayGetLevels()) @@ -493,12 +500,21 @@ void USpudState::RestoreLevel(ULevel* Level) UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Start"), *LevelName); TMap RuntimeObjectsByGuid; + + TArray CrossCellActors; + // Respawn dynamic actors first; they need to exist in order for cross-references in level actors to work for (auto&& SpawnedActor : LevelData->SpawnedActors.Contents) { auto Actor = RespawnActor(SpawnedActor.Value, LevelData->Metadata, Level); if (Actor) + { + if (Actor->Implements()) + { + CrossCellActors.Add(Actor); + } RuntimeObjectsByGuid.Add(SpawnedActor.Value.Guid, Actor); + } // Spawned actors will have been added to Level->Actors, their state will be restored there } @@ -509,6 +525,12 @@ void USpudState::RestoreLevel(ULevel* Level) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { + //Когда мы востанавливаем пресистант мы не сохраняем бегуна + if (Actor->Implements()) + { + continue; + } + RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); if (Guid.IsValid()) @@ -536,6 +558,39 @@ void USpudState::RestoreLevel(ULevel* Level) } } } + + for (auto Actor : CrossCellActors) + { + if (SpudPropertyUtil::IsPersistentObject(Actor)) + { + RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); + auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); + if (Guid.IsValid()) + { + if (RuntimeObjectsByGuid.Contains(Guid)) + { + if (const auto DuplicatedActor = RestoredRuntimeActors.Find(Guid)) + { + UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - destroying duplicate runtime actor %s"), + *LevelName, *Guid.ToString(EGuidFormats::DigitsWithHyphens)); + // sometimes runtime actors are duplicated in the level actors array - for example, when hiding a + // world partition cell and immediately showing it; need to remove duplicates in this case + (*DuplicatedActor)->Destroy(); + } + else + { + RestoredRuntimeActors.Emplace(Guid, Actor); + } + } + else + { + RuntimeObjectsByGuid.Add(Guid, Actor); + } + } + } + } + + // Destroy actors in level but missing from save state for (auto&& DestroyedActor : LevelData->DestroyedActors.Values) { @@ -583,8 +638,19 @@ AActor* USpudState::RespawnActor(const FSpudSpawnedActorData& SpawnedActor, UE_LOG(LogSpudState, Error, TEXT("Cannot respawn instance of %s, class not found"), *ClassName); return nullptr; } + + FActorSpawnParameters Params; - Params.OverrideLevel = Level; + + if (Class->ImplementsInterface(USpudWanderingActor::StaticClass())) + { + Params.OverrideLevel = Level->GetWorld()->PersistentLevel; + } + else + { + Params.OverrideLevel = Level; + } + // Need to always spawn since we're not setting position until later Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; UE_LOG(LogSpudState, Verbose, TEXT(" * Respawning actor %s of type %s"), *SpawnedActor.Guid.ToString(EGuidFormats::DigitsWithHyphens), *ClassName); diff --git a/Source/SPUD/Private/SpudSubsystem.cpp b/Source/SPUD/Private/SpudSubsystem.cpp index b85b9d1..9378d18 100644 --- a/Source/SPUD/Private/SpudSubsystem.cpp +++ b/Source/SPUD/Private/SpudSubsystem.cpp @@ -4,7 +4,6 @@ #include "Engine/LocalPlayer.h" #include "Kismet/GameplayStatics.h" #include "ImageUtils.h" -#include "SpudRuntimeStoredActorComponent.h" #include "TimerManager.h" #include "HAL/FileManager.h" #include "Async/Async.h" @@ -1002,27 +1001,6 @@ void USpudSubsystem::UnloadStreamLevel(FName LevelName) } } -void USpudSubsystem::UpdateRegisteredComps() -{ - // Ticking registered comp's owner is moving outside the loaded area. - TArray NeedToDestroyArray; - for (const auto RegComp : RegisteredRuntimeStoredActorComponents) - { - bool bCellActivated; - RegComp->UpdateCurrentCell(bCellActivated); - if (!bCellActivated) - { - NeedToDestroyArray.Add(RegComp); - } - } - - for (auto Destroy : NeedToDestroyArray) - { - StoreActorByCell(Destroy->GetOwner(), Destroy->CurrentCellName); - Destroy->DestroyActor(); - } -} - void USpudSubsystem::ForceReset() { CurrentState = ESpudSystemState::RunningIdle; @@ -1587,8 +1565,6 @@ void USpudSubsystem::Tick(float DeltaTime) PostUnloadStreamingLevel.Broadcast(FName(USpudState::GetLevelName(Level->GetWorldAssetPackageName()))); } } - - UpdateRegisteredComps(); } } } diff --git a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp new file mode 100644 index 0000000..74ee425 --- /dev/null +++ b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp @@ -0,0 +1,341 @@ +#include "WanderingActorTrackerSubsystem.h" + +#include "SpudSubsystem.h" +#include "WorldPartition/WorldPartitionSubsystem.h" + +// Console variable to toggle debug drawing of WP cell cache bounds at runtime +// Usage: WanderingActorTracker.DebugDrawCells 1 +static TAutoConsoleVariable CVarDebugDrawCells( + TEXT("WanderingActorTracker.DebugDrawCells"), + false, + TEXT("Draw debug boxes for WP cell cache") +); + +void UWanderingActorTrackerSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + const UGameInstance* GI = GetWorld()->GetGameInstance(); + if (!GI) return; + + CachedSpudSubsystem = GI->GetSubsystem(); + + if (USpudSubsystem* Spud = CachedSpudSubsystem.Get()) + { + // OnLevelStore fires when SPUD saves a specific streaming level + Spud->OnLevelStore.AddDynamic(this, &ThisClass::OnLevelStore); + // Track streaming level load/unload to keep cell state cache up to date + Spud->PostLoadStreamingLevel.AddDynamic(this, &ThisClass::OnPostLoadStreamingLevel); + Spud->PostUnloadStreamingLevel.AddDynamic(this, &ThisClass::OnPostUnloadStreamingLevel); + } +} + +void UWanderingActorTrackerSubsystem::Deinitialize() +{ + if (USpudSubsystem* Spud = CachedSpudSubsystem.Get()) + { + Spud->OnLevelStore.RemoveDynamic(this, &ThisClass::OnLevelStore); + Spud->PostLoadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPostLoadStreamingLevel); + Spud->PostUnloadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPostUnloadStreamingLevel); + } + + CachedSpudSubsystem = nullptr; + TrackedActors.Empty(); + CellCache.Empty(); + + Super::Deinitialize(); +} + +// Only create this subsystem in game worlds +bool UWanderingActorTrackerSubsystem::ShouldCreateSubsystem(UObject* Outer) const +{ + const UWorld* World = Cast(Outer); + return World && World->IsGameWorld(); +} + +void UWanderingActorTrackerSubsystem::RegisterActor(AActor* Actor) +{ + if (!Actor) return; + + // Prevent duplicate registration + if (TrackedActors.ContainsByPredicate([Actor](const FTrackedActor& T) { return T.Actor == Actor; })) + return; + + FTrackedActor& NewTracked = TrackedActors.AddDefaulted_GetRef(); + NewTracked.Actor = Actor; + // LastValidCellName will be populated on the first Tick +} + +void UWanderingActorTrackerSubsystem::UnregisterActor(AActor* Actor) +{ + if (!Actor) return; + + TrackedActors.RemoveAll([Actor](const FTrackedActor& T) { return T.Actor == Actor; }); +} + +// Called by SPUD when it is about to save a specific streaming level. +// We store each tracked actor into whichever cell it physically occupies, +// falling back to LastValidCellName if no cell is found at the current location. +void UWanderingActorTrackerSubsystem::OnLevelStore(const FString& LevelName) +{ + USpudSubsystem* Spud = CachedSpudSubsystem.Get(); + if (!Spud) return; + + for (auto& [Actor, LastValidCellName] : TrackedActors) + { + if (!Actor.IsValid()) continue; + + // Find the physical cell the actor is currently inside + FString CurrentCell; + bool bIsActivated = false; + FindCellForLocation(Actor->GetActorLocation(), CurrentCell, bIsActivated); + + // Prefer the physical cell; fall back to last known valid cell + const FString& TargetCell = !CurrentCell.IsEmpty() + ? CurrentCell + : LastValidCellName; + + if (TargetCell.IsEmpty()) continue; + + // Only store if this is the level SPUD is currently saving + if (TargetCell != LevelName) continue; + + Spud->StoreActorByCell(Actor.Get(), TargetCell); + } +} + +void UWanderingActorTrackerSubsystem::OnPostUnloadStreamingLevel(const FName& LevelName) +{ + OnStreamingStateUpdated(); +} + +void UWanderingActorTrackerSubsystem::OnPostLoadStreamingLevel(const FName& LevelName) +{ + OnStreamingStateUpdated(); +} + +// Keeps the cell state cache in sync with the current WP streaming state. +// If the number of valid cells has changed, performs a full rebuild. +// Otherwise just refreshes the State field of each cached entry. +void UWanderingActorTrackerSubsystem::OnStreamingStateUpdated() +{ + UWorldPartitionSubsystem* WorldPartitionSubsystem = GetWorld()->GetSubsystem(); + if (!WorldPartitionSubsystem) return; + + // Count only cells with valid content + int32 ActualCellCount = 0; + WorldPartitionSubsystem->ForEachWorldPartition([&ActualCellCount](const UWorldPartition* WorldPartition) -> bool + { + if (WorldPartition) + WorldPartition->RuntimeHash->ForEachStreamingCells([&ActualCellCount](const UWorldPartitionRuntimeCell* Cell) -> bool + { + if (Cell && Cell->GetContentBounds().IsValid) + ++ActualCellCount; + return true; + }); + return true; + }); + + // Cell count mismatch means cells were added or removed + if (ActualCellCount != CellCache.Num()) + { + RebuildCellCache(); + return; + } + + // Update only the streaming state of each cached cell + for (auto& Data : CellCache) + Data.State = Data.Cell->GetCurrentState(); +} + +// Rebuilds the cell cache from scratch by iterating all WP streaming cells. +// Cells without valid content bounds are skipped +void UWanderingActorTrackerSubsystem::RebuildCellCache() +{ + CellCache.Empty(); + + UWorldPartitionSubsystem* WorldPartitionSubsystem = GetWorld()->GetSubsystem(); + if (!WorldPartitionSubsystem) return; + + auto ForEachCellFunction = [this](const UWorldPartitionRuntimeCell* Cell) -> bool + { + if (Cell) + { + // Skip cells with no content + if (!Cell->GetContentBounds().IsValid) return true; + + FCachedCellData& Data = CellCache.AddDefaulted_GetRef(); + Data.Cell = Cell; + Data.Bounds = Cell->GetCellBounds(); + Data.LevelName = USpudState::GetLevelName(Cell); + Data.State = Cell->GetCurrentState(); + } + return true; + }; + + auto ForEachWPFunction = [&ForEachCellFunction](const UWorldPartition* WorldPartition) -> bool + { + if (WorldPartition) + WorldPartition->RuntimeHash->ForEachStreamingCells(ForEachCellFunction); + return true; + }; + + WorldPartitionSubsystem->ForEachWorldPartition(ForEachWPFunction); +} + +// Finds the most specific WP cell that contains the given location. +// "Most specific" = smallest XY area (e.g. a house cell inside a landscape cell). +bool UWanderingActorTrackerSubsystem::FindCellForLocation( + const FVector& Location, + FString& OutCellName, + bool& OutIsActivated) const +{ + float SmallestArea = 0.f; + int32 BestIndex = INDEX_NONE; + + for (int32 i = 0; i < CellCache.Num(); ++i) + { + const FCachedCellData& Data = CellCache[i]; + + if (!Data.Bounds.IsInside(Location)) continue; + + const FVector Size = Data.Bounds.GetSize(); + const float Area = Size.X * Size.Y; + + if (BestIndex == INDEX_NONE || Area < SmallestArea) + { + SmallestArea = Area; + BestIndex = i; + } + } + + if (BestIndex == INDEX_NONE) + return false; + + OutCellName = CellCache[BestIndex].LevelName; + OutIsActivated = CellCache[BestIndex].State == EWorldPartitionRuntimeCellState::Activated; + return true; +} + +// Clamps the actor's location to within the bounds of the given cell (with a small inset). +// This ensures the actor restores strictly inside its cell and doesn't immediately +// trigger the unload logic due to a boundary position. +void UWanderingActorTrackerSubsystem::ClampActorToCell(AActor* Actor, const FString& CellName) const +{ + const FCachedCellData* TargetCell = CellCache.FindByPredicate([&CellName](const FCachedCellData& Data) + { + return Data.LevelName == CellName; + }); + + if (!TargetCell) return; + + // Small inset to avoid placing the actor exactly on the cell boundary + static constexpr float Inset = 10.f; + + const FVector Location = Actor->GetActorLocation(); + const FVector Clamped = FVector( + FMath::Clamp(Location.X, TargetCell->Bounds.Min.X + Inset, TargetCell->Bounds.Max.X - Inset), + FMath::Clamp(Location.Y, TargetCell->Bounds.Min.Y + Inset, TargetCell->Bounds.Max.Y - Inset), + FMath::Clamp(Location.Z, TargetCell->Bounds.Min.Z + Inset, TargetCell->Bounds.Max.Z - Inset) + ); + + if (!Clamped.Equals(Location)) + Actor->SetActorLocation(Clamped); +} + +// Clamps the actor into its target cell, stores it in SPUD, then queues it for destruction. +// Actual Destroy() is deferred to after the Tick loop to avoid invalidating iterators. +void UWanderingActorTrackerSubsystem::SaveAndDestroyActor( + FTrackedActor& Tracked, + const FString& CellName, + USpudSubsystem* Spud, + TArray& OutActorsToDestroy) +{ + ClampActorToCell(Tracked.Actor.Get(), CellName); + + if (Spud) + Spud->StoreActorByCell(Tracked.Actor.Get(), CellName); + + OutActorsToDestroy.Add(Tracked.Actor.Get()); +} + +void UWanderingActorTrackerSubsystem::Tick(float DeltaTime) +{ + // Only tick on the authority + if (!GetWorld()->GetAuthGameMode()) return; + + USpudSubsystem* Spud = CachedSpudSubsystem.Get(); + if (!Spud) return; + + // Collect actors to destroy after the loop to avoid iterator invalidation + TArray ActorsToDestroy; + + for (auto It = TrackedActors.CreateIterator(); It; ++It) + { + FTrackedActor& Tracked = *It; + + if (!Tracked.Actor.IsValid()) + { + It.RemoveCurrent(); + continue; + } + + FString CurrentCell; + bool bIsActivated = false; + const bool bFound = FindCellForLocation(Tracked.Actor->GetActorLocation(), CurrentCell, bIsActivated); + + if (bFound && bIsActivated) + { + // If Actor is in an active cell, update last known valid cell + Tracked.LastValidCellName = CurrentCell; + } + else if (bFound && !bIsActivated) + { + //If actor's cell is inactive, save into that cell and destroy + SaveAndDestroyActor(Tracked, CurrentCell, Spud, ActorsToDestroy); + } + else if (!bFound && !Tracked.LastValidCellName.IsEmpty()) + { + // IF actor is outside all cell bounds, fall back to last known cell + SaveAndDestroyActor(Tracked, Tracked.LastValidCellName, Spud, ActorsToDestroy); + } + else + { + //If no cell found and no fallback, cannot save, log a warning + UE_LOG(LogTemp, Warning, TEXT("WanderingActorTracker: %s has no valid cell, cannot save"), + *Tracked.Actor->GetName()); + } + } + + // Destroy queued actors — + for (AActor* Actor : ActorsToDestroy) + { + // Suppress network replication before destroying + Actor->SetNetDormancy(DORM_DormantAll); + Actor->Destroy(); + } + +#if ENABLE_DRAW_DEBUG + // Visualize cell cache bounds + // Enable via console: WanderingActorTracker.DebugDrawCells 1 + if (CVarDebugDrawCells.GetValueOnGameThread()) + { + for (const FCachedCellData& Data : CellCache) + { + const FColor Color = Data.State == EWorldPartitionRuntimeCellState::Activated + ? FColor::Green + : FColor::Red; + + DrawDebugBox(GetWorld(), + Data.Bounds.GetCenter(), + Data.Bounds.GetExtent(), + Color, + false, + 0.f, + 0, + 50.f + ); + } + } +#endif +} \ No newline at end of file diff --git a/Source/SPUD/Public/ISpudObject.h b/Source/SPUD/Public/ISpudObject.h index 0716e1c..45b5ccc 100644 --- a/Source/SPUD/Public/ISpudObject.h +++ b/Source/SPUD/Public/ISpudObject.h @@ -142,3 +142,20 @@ class SPUD_API ISpudObjectCallback void SpudPostRestore(const USpudState* State); }; + +UINTERFACE(MinimalAPI) +class USpudWanderingActor : public UInterface +{ + GENERATED_BODY() +}; + +/** +* Opts an actor into the WanderingActorTracker system. +* Implement this interface on actors that can move between World Partition cells. +* The tracker subsystem will automatically manage saving and destroying them +* when they leave loaded cells. +*/ +class SPUD_API ISpudWanderingActor +{ + GENERATED_BODY() +}; \ No newline at end of file diff --git a/Source/SPUD/Public/SpudRuntimeStoredActorComponent.h b/Source/SPUD/Public/SpudRuntimeStoredActorComponent.h index ce5b24e..4ac3b46 100644 --- a/Source/SPUD/Public/SpudRuntimeStoredActorComponent.h +++ b/Source/SPUD/Public/SpudRuntimeStoredActorComponent.h @@ -21,27 +21,7 @@ class SPUD_API USpudRuntimeStoredActorComponent : public UActorComponent * Can the owning actor can cross cells? Ticking will be enabled if this flag is true. If the position changes * infrequently, it's more efficient to manually call UpdateCurrentCell(). */ - UPROPERTY(EditDefaultsOnly) - bool bCanCrossCell = false; - - FString CurrentCellName; - - void UpdateCurrentCell(bool& CellActivated); - void DestroyActor(); - protected: virtual void BeginPlay() override; virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; - -private: - UFUNCTION() - void OnLevelStore(const FString& LevelName); - - UFUNCTION() - void OnPostUnloadCell(const FName& LevelName); - - UFUNCTION() - void OnPreUnloadCell(const FName& LevelName); - - void GetCurrentOverlappedCell(const UWorldPartitionRuntimeCell*& CurrentOverlappedCell) const; }; diff --git a/Source/SPUD/Public/SpudSubsystem.h b/Source/SPUD/Public/SpudSubsystem.h index ab96f55..774aa39 100644 --- a/Source/SPUD/Public/SpudSubsystem.h +++ b/Source/SPUD/Public/SpudSubsystem.h @@ -10,7 +10,6 @@ #include "SpudSubsystem.generated.h" -class USpudRuntimeStoredActorComponent; DECLARE_LOG_CATEGORY_EXTERN(LogSpudSubsystem, Verbose, Verbose); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FSpudPreLoadGame, const FString&, SlotName); @@ -164,9 +163,6 @@ class SPUD_API USpudSubsystem : public UGameInstanceSubsystem, public FTickableG UPROPERTY(BlueprintReadWrite, Config) TArray ExcludeLevelNamePatterns; - UPROPERTY(BlueprintReadOnly) - TSet> RegisteredRuntimeStoredActorComponents; - protected: FDelegateHandle OnPreLoadMapHandle; FDelegateHandle OnPostLoadMapHandle; diff --git a/Source/SPUD/Public/WanderingActorTrackerSubsystem.h b/Source/SPUD/Public/WanderingActorTrackerSubsystem.h new file mode 100644 index 0000000..e088d5a --- /dev/null +++ b/Source/SPUD/Public/WanderingActorTrackerSubsystem.h @@ -0,0 +1,68 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/WorldSubsystem.h" +#include "WorldPartition/WorldPartitionLevelStreamingPolicy.h" +#include "WanderingActorTrackerSubsystem.generated.h" + +class USpudSubsystem; + +UCLASS() +class SPUD_API UWanderingActorTrackerSubsystem : public UTickableWorldSubsystem +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = "WanderingActorTracker") + void RegisterActor(AActor* Actor); + + UFUNCTION(BlueprintCallable, Category = "WanderingActorTracker") + void UnregisterActor(AActor* Actor); + +protected: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + virtual bool ShouldCreateSubsystem(UObject* Outer) const override; + + virtual TStatId GetStatId() const override + { + RETURN_QUICK_DECLARE_CYCLE_STAT(UWanderingActorTrackerSubsystem, STATGROUP_Tickables); + } + + virtual void Tick(float DeltaTime) override; + virtual bool IsTickableWhenPaused() const override { return false; } + + UFUNCTION() + void OnLevelStore(const FString& LevelName); + + UFUNCTION() + void OnPostLoadStreamingLevel(const FName& LevelName); + + UFUNCTION() + void OnPostUnloadStreamingLevel(const FName& LevelName); + +private: + struct FTrackedActor + { + TWeakObjectPtr Actor; + FString LastValidCellName; + }; + + struct FCachedCellData + { + const UWorldPartitionRuntimeCell* Cell; + FBox Bounds; + FString LevelName; + EWorldPartitionRuntimeCellState State; + }; + + TArray TrackedActors; + TArray CellCache; + TWeakObjectPtr CachedSpudSubsystem; + + void OnStreamingStateUpdated(); + void RebuildCellCache(); + bool FindCellForLocation(const FVector& Location, FString& OutCellName, bool& OutIsActivated) const; + void ClampActorToCell(AActor* Actor, const FString& CellName) const; + void SaveAndDestroyActor(FTrackedActor& Tracked, const FString& CellName, USpudSubsystem* Spud, TArray& OutActorsToDestroy); +}; \ No newline at end of file From fe8d30d04c0542625170855a96f15bf2482a6097 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:54:49 +0300 Subject: [PATCH 02/10] Remove duplicate destruction on actor restoration I think the duplicate removal logic is excessive it may hide issues with the save/restore procces --- Source/SPUD/Private/SpudState.cpp | 58 ++++--------------------------- 1 file changed, 6 insertions(+), 52 deletions(-) diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index cd9d451..2747302 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -517,48 +517,22 @@ void USpudState::RestoreLevel(ULevel* Level) } // Spawned actors will have been added to Level->Actors, their state will be restored there } - - TMap RestoredRuntimeActors; - - // Restore existing actor state + for (auto Actor : Level->Actors) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { - //Когда мы востанавливаем пресистант мы не сохраняем бегуна + //Skip Wandering Actor restoration by PersistentLevel if (Actor->Implements()) - { continue; - } - + RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); if (Guid.IsValid()) - { - if (RuntimeObjectsByGuid.Contains(Guid)) - { - if (const auto DuplicatedActor = RestoredRuntimeActors.Find(Guid)) - { - UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - destroying duplicate runtime actor %s"), - *LevelName, *Guid.ToString(EGuidFormats::DigitsWithHyphens)); - - // sometimes runtime actors are duplicated in the level actors array - for example, when hiding a - // world partition cell and immediately showing it; need to remove duplicates in this case - (*DuplicatedActor)->Destroy(); - } - else - { - RestoredRuntimeActors.Emplace(Guid, Actor); - } - } - else - { - RuntimeObjectsByGuid.Add(Guid, Actor); - } - } + RuntimeObjectsByGuid.Add(Guid, Actor); } } - + for (auto Actor : CrossCellActors) { if (SpudPropertyUtil::IsPersistentObject(Actor)) @@ -566,27 +540,7 @@ void USpudState::RestoreLevel(ULevel* Level) RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); if (Guid.IsValid()) - { - if (RuntimeObjectsByGuid.Contains(Guid)) - { - if (const auto DuplicatedActor = RestoredRuntimeActors.Find(Guid)) - { - UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - destroying duplicate runtime actor %s"), - *LevelName, *Guid.ToString(EGuidFormats::DigitsWithHyphens)); - // sometimes runtime actors are duplicated in the level actors array - for example, when hiding a - // world partition cell and immediately showing it; need to remove duplicates in this case - (*DuplicatedActor)->Destroy(); - } - else - { - RestoredRuntimeActors.Emplace(Guid, Actor); - } - } - else - { - RuntimeObjectsByGuid.Add(Guid, Actor); - } - } + RuntimeObjectsByGuid.Add(Guid, Actor); } } From fb679793a572fcfa83360a38e0d8f2468de27b0b Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:05:31 +0300 Subject: [PATCH 03/10] Change GetCellBounds to GetStreamingBounds --- Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp index 74ee425..bc4f1c1 100644 --- a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp +++ b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp @@ -166,7 +166,7 @@ void UWanderingActorTrackerSubsystem::RebuildCellCache() FCachedCellData& Data = CellCache.AddDefaulted_GetRef(); Data.Cell = Cell; - Data.Bounds = Cell->GetCellBounds(); + Data.Bounds = Cell->GetStreamingBounds(); Data.LevelName = USpudState::GetLevelName(Cell); Data.State = Cell->GetCurrentState(); } @@ -212,7 +212,7 @@ bool UWanderingActorTrackerSubsystem::FindCellForLocation( if (BestIndex == INDEX_NONE) return false; - OutCellName = CellCache[BestIndex].LevelName; + OutCellName = CellCache[BestIndex].LevelName; OutIsActivated = CellCache[BestIndex].State == EWorldPartitionRuntimeCellState::Activated; return true; } From 884c83dd4eff69182ad271969ffe41a1181cff37 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:02:24 +0300 Subject: [PATCH 04/10] Remove check for cell content --- Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp index bc4f1c1..251c5a9 100644 --- a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp +++ b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp @@ -129,7 +129,7 @@ void UWanderingActorTrackerSubsystem::OnStreamingStateUpdated() if (WorldPartition) WorldPartition->RuntimeHash->ForEachStreamingCells([&ActualCellCount](const UWorldPartitionRuntimeCell* Cell) -> bool { - if (Cell && Cell->GetContentBounds().IsValid) + //if (Cell && Cell->GetContentBounds().IsValid) ++ActualCellCount; return true; }); @@ -162,7 +162,7 @@ void UWanderingActorTrackerSubsystem::RebuildCellCache() if (Cell) { // Skip cells with no content - if (!Cell->GetContentBounds().IsValid) return true; + //if (!Cell->GetContentBounds().IsValid) return true; FCachedCellData& Data = CellCache.AddDefaulted_GetRef(); Data.Cell = Cell; From df6420749a5a285942d2a2e67ef32bb141281ca3 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:50:51 +0300 Subject: [PATCH 05/10] Fix store to multiple cells --- .../WanderingActorTrackerSubsystem.cpp | 79 ++++++++++++++++--- .../Public/WanderingActorTrackerSubsystem.h | 4 + 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp index 251c5a9..bc5bdb0 100644 --- a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp +++ b/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp @@ -26,7 +26,9 @@ void UWanderingActorTrackerSubsystem::Initialize(FSubsystemCollectionBase& Colle Spud->OnLevelStore.AddDynamic(this, &ThisClass::OnLevelStore); // Track streaming level load/unload to keep cell state cache up to date Spud->PostLoadStreamingLevel.AddDynamic(this, &ThisClass::OnPostLoadStreamingLevel); + Spud->PreUnloadStreamingLevel.AddDynamic(this, &ThisClass::OnPreUnloadStreamingLevel); Spud->PostUnloadStreamingLevel.AddDynamic(this, &ThisClass::OnPostUnloadStreamingLevel); + } } @@ -36,6 +38,7 @@ void UWanderingActorTrackerSubsystem::Deinitialize() { Spud->OnLevelStore.RemoveDynamic(this, &ThisClass::OnLevelStore); Spud->PostLoadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPostLoadStreamingLevel); + Spud->PreUnloadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPreUnloadStreamingLevel); Spud->PostUnloadStreamingLevel.RemoveDynamic(this, &ThisClass::OnPostUnloadStreamingLevel); } @@ -50,7 +53,10 @@ void UWanderingActorTrackerSubsystem::Deinitialize() bool UWanderingActorTrackerSubsystem::ShouldCreateSubsystem(UObject* Outer) const { const UWorld* World = Cast(Outer); - return World && World->IsGameWorld(); + if (!World || !World->IsGameWorld()) return false; + + // Only create on server / standalone + return World->GetNetMode() != NM_Client; } void UWanderingActorTrackerSubsystem::RegisterActor(AActor* Actor) @@ -78,39 +84,90 @@ void UWanderingActorTrackerSubsystem::UnregisterActor(AActor* Actor) // falling back to LastValidCellName if no cell is found at the current location. void UWanderingActorTrackerSubsystem::OnLevelStore(const FString& LevelName) { + // Only save on the authority + if (!GetWorld()->GetAuthGameMode()) return; + USpudSubsystem* Spud = CachedSpudSubsystem.Get(); if (!Spud) return; - for (auto& [Actor, LastValidCellName] : TrackedActors) + TArray ActorsToDestroy; + + for (FTrackedActor& Tracked : TrackedActors) { - if (!Actor.IsValid()) continue; + if (!Tracked.Actor.IsValid()) continue; - // Find the physical cell the actor is currently inside FString CurrentCell; bool bIsActivated = false; - FindCellForLocation(Actor->GetActorLocation(), CurrentCell, bIsActivated); + FindCellForLocation(Tracked.Actor->GetActorLocation(), CurrentCell, bIsActivated); - // Prefer the physical cell; fall back to last known valid cell const FString& TargetCell = !CurrentCell.IsEmpty() ? CurrentCell - : LastValidCellName; + : Tracked.LastValidCellName; if (TargetCell.IsEmpty()) continue; - - // Only store if this is the level SPUD is currently saving if (TargetCell != LevelName) continue; - Spud->StoreActorByCell(Actor.Get(), TargetCell); + const FCachedCellData* CellData = CellCache.FindByPredicate([&TargetCell](const FCachedCellData& D) + { + return D.LevelName == TargetCell; + }); + + if (CellData && CellData->bPendingUnload) + { + // Cell is about to unload, clamp, save and destroy + SaveAndDestroyActor(Tracked, TargetCell, Spud, ActorsToDestroy); + } + else + { + // Normal save, just store without destroying + Spud->StoreActorByCell(Tracked.Actor.Get(), TargetCell); + } + } + + for (AActor* Actor : ActorsToDestroy) + { + //Actor->SetNetDormancy(DORM_DormantAll); + Actor->Destroy(); + } +} + +void UWanderingActorTrackerSubsystem::OnPreUnloadStreamingLevel(const FName& LevelName) +{ + for (auto& Data : CellCache) + { + if (Data.LevelName == LevelName.ToString()) + { + Data.bPendingUnload = true; + break; + } } } void UWanderingActorTrackerSubsystem::OnPostUnloadStreamingLevel(const FName& LevelName) { + for (auto& Data : CellCache) + { + if (Data.LevelName == LevelName.ToString()) + { + Data.bPendingUnload = false; + break; + } + } + OnStreamingStateUpdated(); } void UWanderingActorTrackerSubsystem::OnPostLoadStreamingLevel(const FName& LevelName) { + for (auto& Data : CellCache) + { + if (Data.LevelName == LevelName.ToString()) + { + Data.bPendingUnload = false; + break; + } + } + OnStreamingStateUpdated(); } @@ -311,7 +368,7 @@ void UWanderingActorTrackerSubsystem::Tick(float DeltaTime) for (AActor* Actor : ActorsToDestroy) { // Suppress network replication before destroying - Actor->SetNetDormancy(DORM_DormantAll); + //Actor->SetNetDormancy(DORM_DormantAll); Actor->Destroy(); } diff --git a/Source/SPUD/Public/WanderingActorTrackerSubsystem.h b/Source/SPUD/Public/WanderingActorTrackerSubsystem.h index e088d5a..02661a4 100644 --- a/Source/SPUD/Public/WanderingActorTrackerSubsystem.h +++ b/Source/SPUD/Public/WanderingActorTrackerSubsystem.h @@ -40,6 +40,9 @@ class SPUD_API UWanderingActorTrackerSubsystem : public UTickableWorldSubsystem UFUNCTION() void OnPostUnloadStreamingLevel(const FName& LevelName); + + UFUNCTION() + void OnPreUnloadStreamingLevel(const FName& LevelName); private: struct FTrackedActor @@ -54,6 +57,7 @@ class SPUD_API UWanderingActorTrackerSubsystem : public UTickableWorldSubsystem FBox Bounds; FString LevelName; EWorldPartitionRuntimeCellState State; + bool bPendingUnload = false; }; TArray TrackedActors; From 3818c0261420635e462fc1db45d4efc8bd0a574f Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:48:21 +0300 Subject: [PATCH 06/10] Rename WanderingActorTrackerSubsystem --- ...stem.cpp => SpudRoamingActorSubsystem.cpp} | 40 +++++++++---------- .../SpudRuntimeStoredActorComponent.cpp | 6 +-- Source/SPUD/Private/SpudState.cpp | 18 ++++----- Source/SPUD/Public/ISpudObject.h | 6 +-- ...ubsystem.h => SpudRoamingActorSubsystem.h} | 10 ++--- 5 files changed, 40 insertions(+), 40 deletions(-) rename Source/SPUD/Private/{WanderingActorTrackerSubsystem.cpp => SpudRoamingActorSubsystem.cpp} (89%) rename Source/SPUD/Public/{WanderingActorTrackerSubsystem.h => SpudRoamingActorSubsystem.h} (83%) diff --git a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp b/Source/SPUD/Private/SpudRoamingActorSubsystem.cpp similarity index 89% rename from Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp rename to Source/SPUD/Private/SpudRoamingActorSubsystem.cpp index bc5bdb0..812a577 100644 --- a/Source/SPUD/Private/WanderingActorTrackerSubsystem.cpp +++ b/Source/SPUD/Private/SpudRoamingActorSubsystem.cpp @@ -1,17 +1,17 @@ -#include "WanderingActorTrackerSubsystem.h" +#include "SpudRoamingActorSubsystem.h" #include "SpudSubsystem.h" #include "WorldPartition/WorldPartitionSubsystem.h" // Console variable to toggle debug drawing of WP cell cache bounds at runtime -// Usage: WanderingActorTracker.DebugDrawCells 1 +// Usage: RoamingActorSubsystem.DebugDrawCells 1 static TAutoConsoleVariable CVarDebugDrawCells( - TEXT("WanderingActorTracker.DebugDrawCells"), + TEXT("RoamingActorSubsystem.DebugDrawCells"), false, TEXT("Draw debug boxes for WP cell cache") ); -void UWanderingActorTrackerSubsystem::Initialize(FSubsystemCollectionBase& Collection) +void USpudRoamingActorSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); @@ -32,7 +32,7 @@ void UWanderingActorTrackerSubsystem::Initialize(FSubsystemCollectionBase& Colle } } -void UWanderingActorTrackerSubsystem::Deinitialize() +void USpudRoamingActorSubsystem::Deinitialize() { if (USpudSubsystem* Spud = CachedSpudSubsystem.Get()) { @@ -50,7 +50,7 @@ void UWanderingActorTrackerSubsystem::Deinitialize() } // Only create this subsystem in game worlds -bool UWanderingActorTrackerSubsystem::ShouldCreateSubsystem(UObject* Outer) const +bool USpudRoamingActorSubsystem::ShouldCreateSubsystem(UObject* Outer) const { const UWorld* World = Cast(Outer); if (!World || !World->IsGameWorld()) return false; @@ -59,7 +59,7 @@ bool UWanderingActorTrackerSubsystem::ShouldCreateSubsystem(UObject* Outer) cons return World->GetNetMode() != NM_Client; } -void UWanderingActorTrackerSubsystem::RegisterActor(AActor* Actor) +void USpudRoamingActorSubsystem::RegisterActor(AActor* Actor) { if (!Actor) return; @@ -72,7 +72,7 @@ void UWanderingActorTrackerSubsystem::RegisterActor(AActor* Actor) // LastValidCellName will be populated on the first Tick } -void UWanderingActorTrackerSubsystem::UnregisterActor(AActor* Actor) +void USpudRoamingActorSubsystem::UnregisterActor(AActor* Actor) { if (!Actor) return; @@ -82,7 +82,7 @@ void UWanderingActorTrackerSubsystem::UnregisterActor(AActor* Actor) // Called by SPUD when it is about to save a specific streaming level. // We store each tracked actor into whichever cell it physically occupies, // falling back to LastValidCellName if no cell is found at the current location. -void UWanderingActorTrackerSubsystem::OnLevelStore(const FString& LevelName) +void USpudRoamingActorSubsystem::OnLevelStore(const FString& LevelName) { // Only save on the authority if (!GetWorld()->GetAuthGameMode()) return; @@ -131,7 +131,7 @@ void UWanderingActorTrackerSubsystem::OnLevelStore(const FString& LevelName) } } -void UWanderingActorTrackerSubsystem::OnPreUnloadStreamingLevel(const FName& LevelName) +void USpudRoamingActorSubsystem::OnPreUnloadStreamingLevel(const FName& LevelName) { for (auto& Data : CellCache) { @@ -143,7 +143,7 @@ void UWanderingActorTrackerSubsystem::OnPreUnloadStreamingLevel(const FName& Lev } } -void UWanderingActorTrackerSubsystem::OnPostUnloadStreamingLevel(const FName& LevelName) +void USpudRoamingActorSubsystem::OnPostUnloadStreamingLevel(const FName& LevelName) { for (auto& Data : CellCache) { @@ -157,7 +157,7 @@ void UWanderingActorTrackerSubsystem::OnPostUnloadStreamingLevel(const FName& Le OnStreamingStateUpdated(); } -void UWanderingActorTrackerSubsystem::OnPostLoadStreamingLevel(const FName& LevelName) +void USpudRoamingActorSubsystem::OnPostLoadStreamingLevel(const FName& LevelName) { for (auto& Data : CellCache) { @@ -174,7 +174,7 @@ void UWanderingActorTrackerSubsystem::OnPostLoadStreamingLevel(const FName& Leve // Keeps the cell state cache in sync with the current WP streaming state. // If the number of valid cells has changed, performs a full rebuild. // Otherwise just refreshes the State field of each cached entry. -void UWanderingActorTrackerSubsystem::OnStreamingStateUpdated() +void USpudRoamingActorSubsystem::OnStreamingStateUpdated() { UWorldPartitionSubsystem* WorldPartitionSubsystem = GetWorld()->GetSubsystem(); if (!WorldPartitionSubsystem) return; @@ -207,7 +207,7 @@ void UWanderingActorTrackerSubsystem::OnStreamingStateUpdated() // Rebuilds the cell cache from scratch by iterating all WP streaming cells. // Cells without valid content bounds are skipped -void UWanderingActorTrackerSubsystem::RebuildCellCache() +void USpudRoamingActorSubsystem::RebuildCellCache() { CellCache.Empty(); @@ -242,7 +242,7 @@ void UWanderingActorTrackerSubsystem::RebuildCellCache() // Finds the most specific WP cell that contains the given location. // "Most specific" = smallest XY area (e.g. a house cell inside a landscape cell). -bool UWanderingActorTrackerSubsystem::FindCellForLocation( +bool USpudRoamingActorSubsystem::FindCellForLocation( const FVector& Location, FString& OutCellName, bool& OutIsActivated) const @@ -277,7 +277,7 @@ bool UWanderingActorTrackerSubsystem::FindCellForLocation( // Clamps the actor's location to within the bounds of the given cell (with a small inset). // This ensures the actor restores strictly inside its cell and doesn't immediately // trigger the unload logic due to a boundary position. -void UWanderingActorTrackerSubsystem::ClampActorToCell(AActor* Actor, const FString& CellName) const +void USpudRoamingActorSubsystem::ClampActorToCell(AActor* Actor, const FString& CellName) const { const FCachedCellData* TargetCell = CellCache.FindByPredicate([&CellName](const FCachedCellData& Data) { @@ -302,7 +302,7 @@ void UWanderingActorTrackerSubsystem::ClampActorToCell(AActor* Actor, const FStr // Clamps the actor into its target cell, stores it in SPUD, then queues it for destruction. // Actual Destroy() is deferred to after the Tick loop to avoid invalidating iterators. -void UWanderingActorTrackerSubsystem::SaveAndDestroyActor( +void USpudRoamingActorSubsystem::SaveAndDestroyActor( FTrackedActor& Tracked, const FString& CellName, USpudSubsystem* Spud, @@ -316,7 +316,7 @@ void UWanderingActorTrackerSubsystem::SaveAndDestroyActor( OutActorsToDestroy.Add(Tracked.Actor.Get()); } -void UWanderingActorTrackerSubsystem::Tick(float DeltaTime) +void USpudRoamingActorSubsystem::Tick(float DeltaTime) { // Only tick on the authority if (!GetWorld()->GetAuthGameMode()) return; @@ -359,7 +359,7 @@ void UWanderingActorTrackerSubsystem::Tick(float DeltaTime) else { //If no cell found and no fallback, cannot save, log a warning - UE_LOG(LogTemp, Warning, TEXT("WanderingActorTracker: %s has no valid cell, cannot save"), + UE_LOG(LogTemp, Warning, TEXT("RoamingActorSubsystem: %s has no valid cell, cannot save"), *Tracked.Actor->GetName()); } } @@ -374,7 +374,7 @@ void UWanderingActorTrackerSubsystem::Tick(float DeltaTime) #if ENABLE_DRAW_DEBUG // Visualize cell cache bounds - // Enable via console: WanderingActorTracker.DebugDrawCells 1 + // Enable via console: RoamingActorSubsystem.DebugDrawCells 1 if (CVarDebugDrawCells.GetValueOnGameThread()) { for (const FCachedCellData& Data : CellCache) diff --git a/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp b/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp index 0e2765c..1b6b709 100644 --- a/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp +++ b/Source/SPUD/Private/SpudRuntimeStoredActorComponent.cpp @@ -1,6 +1,6 @@ #include "SpudRuntimeStoredActorComponent.h" -#include "WanderingActorTrackerSubsystem.h" +#include "SpudRoamingActorSubsystem.h" DEFINE_LOG_CATEGORY_STATIC(SpudRuntimeStoredActorComponent, All, All); @@ -14,13 +14,13 @@ void USpudRuntimeStoredActorComponent::BeginPlay() { Super::BeginPlay(); - if (UWanderingActorTrackerSubsystem* Tracker = GetWorld()->GetSubsystem()) + if (USpudRoamingActorSubsystem* Tracker = GetWorld()->GetSubsystem()) Tracker->RegisterActor(GetOwner()); } void USpudRuntimeStoredActorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason) { - if (UWanderingActorTrackerSubsystem* Tracker = GetWorld()->GetSubsystem()) + if (USpudRoamingActorSubsystem* Tracker = GetWorld()->GetSubsystem()) Tracker->UnregisterActor(GetOwner()); Super::EndPlay(EndPlayReason); diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index 2747302..ae7aa32 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -59,8 +59,8 @@ void USpudState::StoreLevel(ULevel* Level, bool bReleaseAfter, bool bBlocking) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { - // Wandering акторы управляются WanderingActorTrackerSubsystem — пропускаем - if (Actor->Implements()) + // Skip RoamingActors + if (Actor->Implements()) { continue; } @@ -501,7 +501,7 @@ void USpudState::RestoreLevel(ULevel* Level) UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Start"), *LevelName); TMap RuntimeObjectsByGuid; - TArray CrossCellActors; + TArray RoamingActors; // Respawn dynamic actors first; they need to exist in order for cross-references in level actors to work for (auto&& SpawnedActor : LevelData->SpawnedActors.Contents) @@ -509,9 +509,9 @@ void USpudState::RestoreLevel(ULevel* Level) auto Actor = RespawnActor(SpawnedActor.Value, LevelData->Metadata, Level); if (Actor) { - if (Actor->Implements()) + if (Actor->Implements()) { - CrossCellActors.Add(Actor); + RoamingActors.Add(Actor); } RuntimeObjectsByGuid.Add(SpawnedActor.Value.Guid, Actor); } @@ -522,8 +522,8 @@ void USpudState::RestoreLevel(ULevel* Level) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { - //Skip Wandering Actor restoration by PersistentLevel - if (Actor->Implements()) + //Skip RoamingActors restoration by PersistentLevel + if (Actor->Implements()) continue; RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); @@ -533,7 +533,7 @@ void USpudState::RestoreLevel(ULevel* Level) } } - for (auto Actor : CrossCellActors) + for (auto Actor : RoamingActors) { if (SpudPropertyUtil::IsPersistentObject(Actor)) { @@ -596,7 +596,7 @@ AActor* USpudState::RespawnActor(const FSpudSpawnedActorData& SpawnedActor, FActorSpawnParameters Params; - if (Class->ImplementsInterface(USpudWanderingActor::StaticClass())) + if (Class->ImplementsInterface(USpudRoamingActor::StaticClass())) { Params.OverrideLevel = Level->GetWorld()->PersistentLevel; } diff --git a/Source/SPUD/Public/ISpudObject.h b/Source/SPUD/Public/ISpudObject.h index 45b5ccc..2421795 100644 --- a/Source/SPUD/Public/ISpudObject.h +++ b/Source/SPUD/Public/ISpudObject.h @@ -144,18 +144,18 @@ class SPUD_API ISpudObjectCallback }; UINTERFACE(MinimalAPI) -class USpudWanderingActor : public UInterface +class USpudRoamingActor : public UInterface { GENERATED_BODY() }; /** -* Opts an actor into the WanderingActorTracker system. +* Opts an actor into the RoamingActorSubsystem. * Implement this interface on actors that can move between World Partition cells. * The tracker subsystem will automatically manage saving and destroying them * when they leave loaded cells. */ -class SPUD_API ISpudWanderingActor +class SPUD_API ISpudRoamingActor { GENERATED_BODY() }; \ No newline at end of file diff --git a/Source/SPUD/Public/WanderingActorTrackerSubsystem.h b/Source/SPUD/Public/SpudRoamingActorSubsystem.h similarity index 83% rename from Source/SPUD/Public/WanderingActorTrackerSubsystem.h rename to Source/SPUD/Public/SpudRoamingActorSubsystem.h index 02661a4..7da452b 100644 --- a/Source/SPUD/Public/WanderingActorTrackerSubsystem.h +++ b/Source/SPUD/Public/SpudRoamingActorSubsystem.h @@ -3,20 +3,20 @@ #include "CoreMinimal.h" #include "Subsystems/WorldSubsystem.h" #include "WorldPartition/WorldPartitionLevelStreamingPolicy.h" -#include "WanderingActorTrackerSubsystem.generated.h" +#include "SpudRoamingActorSubsystem.generated.h" class USpudSubsystem; UCLASS() -class SPUD_API UWanderingActorTrackerSubsystem : public UTickableWorldSubsystem +class SPUD_API USpudRoamingActorSubsystem : public UTickableWorldSubsystem { GENERATED_BODY() public: - UFUNCTION(BlueprintCallable, Category = "WanderingActorTracker") + UFUNCTION(BlueprintCallable, Category = "RoamingActorSubsystem") void RegisterActor(AActor* Actor); - UFUNCTION(BlueprintCallable, Category = "WanderingActorTracker") + UFUNCTION(BlueprintCallable, Category = "RoamingActorSubsystem") void UnregisterActor(AActor* Actor); protected: @@ -26,7 +26,7 @@ class SPUD_API UWanderingActorTrackerSubsystem : public UTickableWorldSubsystem virtual TStatId GetStatId() const override { - RETURN_QUICK_DECLARE_CYCLE_STAT(UWanderingActorTrackerSubsystem, STATGROUP_Tickables); + RETURN_QUICK_DECLARE_CYCLE_STAT(USpudRoamingActorSubsystem , STATGROUP_Tickables); } virtual void Tick(float DeltaTime) override; From 2f03965ed4f31c1c15a45bca2ebbe767048f8131 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:51:26 +0300 Subject: [PATCH 07/10] Fix WP cell names inconsistency --- Source/SPUD/Private/SpudState.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index ae7aa32..645fd88 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -1435,11 +1435,16 @@ FString USpudState::GetLevelName(const UWorldPartitionRuntimeCell* Cell) { if (!Cell) return ""; - - FString LevelName = Cell->GetWorld()->GetMapName() + "_" + Cell->GetName(); + + FString LevelName = Cell->GetName(); + //For builds, cell name will already match what SPUD uses for WP streaming processing +#if WITH_EDITOR + LevelName = Cell->GetWorld()->GetMapName() + "_" + LevelName; // Strip off PIE prefix, "UEDPIE_N_" where N is a number if (LevelName.StartsWith("UEDPIE_")) LevelName = LevelName.Right(LevelName.Len() - 9); +#endif + return LevelName; } From 4e41eabdc1e011887f00719bad482dfb5c8a8c78 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:48:10 +0300 Subject: [PATCH 08/10] Add PersistentObject check to StoreActorByCell function --- Source/SPUD/Private/SpudSubsystem.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/SPUD/Private/SpudSubsystem.cpp b/Source/SPUD/Private/SpudSubsystem.cpp index 9378d18..d55c4d2 100644 --- a/Source/SPUD/Private/SpudSubsystem.cpp +++ b/Source/SPUD/Private/SpudSubsystem.cpp @@ -1067,6 +1067,9 @@ UTexture2D* USpudSubsystem::GetRenderTargetData(FString Name) void USpudSubsystem::StoreActorByCell(AActor* Actor, const FString& CellName) { + if (!SpudPropertyUtil::IsPersistentObject(Actor)) + return; + GetActiveState()->StoreActor(Actor, CellName); } From 05823cbf688d4c0f10ec79a0e93f73bb85e4bc4c Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:47:03 +0300 Subject: [PATCH 09/10] Fix duplicates during WP streaming Dynamically spawned actors can be retained in a World Partition cell during streaming, until the cell's data is collected by the GC: https://dev.epicgames.com/community/learning/knowledge-base/r6wl/unreal-engine-world-building-guide#wp-limitations To avoid creating and deleting duplicatesl, I added a GUID check. --- Source/SPUD/Private/SpudState.cpp | 154 +++++++++++++++++------------- 1 file changed, 90 insertions(+), 64 deletions(-) diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index 645fd88..727036a 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -483,75 +483,101 @@ void USpudState::RestoreLevel(UWorld* World, const FString& LevelName) void USpudState::RestoreLevel(ULevel* Level) { - if (!IsValid(Level)) - return; - - FString LevelName = GetLevelName(Level); - auto LevelData = GetLevelData(LevelName, false); + if (!IsValid(Level)) + return; - if (!LevelData.IsValid()) - { - UE_LOG(LogSpudState, Log, TEXT("Skipping restore level %s, no data (this may be fine)"), *LevelName); - return; - } + const FString LevelName = GetLevelName(Level); + const auto LevelData = GetLevelData(LevelName, false); - // Mutex lock the level (load and unload events on streaming can be in loading threads) - FScopeLock LevelLock(&LevelData->Mutex); - - UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Start"), *LevelName); - TMap RuntimeObjectsByGuid; + if (!LevelData.IsValid()) + { + UE_LOG(LogSpudState, Log, TEXT("Skipping restore level %s, no data (this may be fine)"), *LevelName); + return; + } - TArray RoamingActors; - - // Respawn dynamic actors first; they need to exist in order for cross-references in level actors to work - for (auto&& SpawnedActor : LevelData->SpawnedActors.Contents) - { - auto Actor = RespawnActor(SpawnedActor.Value, LevelData->Metadata, Level); - if (Actor) - { - if (Actor->Implements()) - { - RoamingActors.Add(Actor); - } - RuntimeObjectsByGuid.Add(SpawnedActor.Value.Guid, Actor); - } - // Spawned actors will have been added to Level->Actors, their state will be restored there - } - - for (auto Actor : Level->Actors) - { - if (SpudPropertyUtil::IsPersistentObject(Actor)) - { - //Skip RoamingActors restoration by PersistentLevel - if (Actor->Implements()) - continue; - - RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); - auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); - if (Guid.IsValid()) - RuntimeObjectsByGuid.Add(Guid, Actor); - } - } + // Mutex lock the level (load and unload events on streaming can be in loading threads) + FScopeLock LevelLock(&LevelData->Mutex); + UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Start"), *LevelName); - for (auto Actor : RoamingActors) - { - if (SpudPropertyUtil::IsPersistentObject(Actor)) - { - RestoreActor(Actor, LevelData, &RuntimeObjectsByGuid); - auto Guid = SpudPropertyUtil::GetGuidProperty(Actor); - if (Guid.IsValid()) - RuntimeObjectsByGuid.Add(Guid, Actor); - } - } - - - // Destroy actors in level but missing from save state - for (auto&& DestroyedActor : LevelData->DestroyedActors.Values) - { - DestroyActor(*DestroyedActor, Level); - } - UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Complete"), *LevelName); + TMap PersistentObjectsByGuid; + TArray LevelActorsToRestore; + TArray RoamingActorsToRestore; + + // Collect persistent actors and pre-populate guid map + for (AActor* Actor : Level->Actors) + { + if (!Actor) continue; + if (!SpudPropertyUtil::IsPersistentObject(Actor)) continue; + LevelActorsToRestore.Add(Actor); + + const FGuid Guid = SpudPropertyUtil::GetGuidProperty(Actor); + if (Guid.IsValid()) + PersistentObjectsByGuid.Add(Guid, Actor); + } + + // Respawn dynamic actors first so cross-references resolve correctly + // Skip if actor already exists on level (e.g. WP cell reloaded before GC collected old actors) + for (auto&& SpawnedActor : LevelData->SpawnedActors.Contents) + { + const FGuid& Guid = SpawnedActor.Value.Guid; + + if (UObject** Existing = PersistentObjectsByGuid.Find(Guid)) + { +#if WITH_EDITOR + if (AActor* ExistingActor = Cast(*Existing)) + { + if (ExistingActor->Implements()) + { + UE_LOG(LogSpudState, Warning, + TEXT("RESTORE level %s - roaming actor %s already exists, something went wrong"), + *LevelName, *ExistingActor->GetName()); + } + } +#endif + continue; + } + + auto Actor = RespawnActor(SpawnedActor.Value, LevelData->Metadata, Level); + if (Actor) + { + if (Actor->Implements()) + RoamingActorsToRestore.Add(Actor); + else + LevelActorsToRestore.Add(Actor); + + PersistentObjectsByGuid.Add(Guid, Actor); + } + } + + // Restore all non-roaming actors + for (AActor* Actor : LevelActorsToRestore) + { + //Skip RoamingActors restoration by PersistentLevel + if (Actor->Implements()) continue; + + RestoreActor(Actor, LevelData, &PersistentObjectsByGuid); + + const FGuid Guid = SpudPropertyUtil::GetGuidProperty(Actor); + if (Guid.IsValid() && !PersistentObjectsByGuid.Contains(Guid)) + PersistentObjectsByGuid.Add(Guid, Actor); + } + + // Restore roaming actors + for (AActor* Actor : RoamingActorsToRestore) + { + RestoreActor(Actor, LevelData, &PersistentObjectsByGuid); + + const FGuid Guid = SpudPropertyUtil::GetGuidProperty(Actor); + if (Guid.IsValid() && !PersistentObjectsByGuid.Contains(Guid)) + PersistentObjectsByGuid.Add(Guid, Actor); + } + + // Destroy actors missing from save state + for (auto&& DestroyedActor : LevelData->DestroyedActors.Values) + DestroyActor(*DestroyedActor, Level); + + UE_LOG(LogSpudState, Verbose, TEXT("RESTORE level %s - Complete"), *LevelName); } bool USpudState::PreLoadLevelData(const FString& LevelName) From a598ce966888d0b23383817ea09e6b6e22585a64 Mon Sep 17 00:00:00 2001 From: Ingarnm <121302342+Ingarnm@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:09:00 +0300 Subject: [PATCH 10/10] Add comment about GC --- Source/SPUD/Private/SpudState.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Source/SPUD/Private/SpudState.cpp b/Source/SPUD/Private/SpudState.cpp index 727036a..b46a262 100644 --- a/Source/SPUD/Private/SpudState.cpp +++ b/Source/SPUD/Private/SpudState.cpp @@ -504,6 +504,17 @@ void USpudState::RestoreLevel(ULevel* Level) TArray RoamingActorsToRestore; // Collect persistent actors and pre-populate guid map + /* + Dynamically spawned actors can be retained in a level during streaming, + until the level's data is collected by the GC: + https://dev.epicgames.com/community/learning/knowledge-base/r6wl/unreal-engine-world-building-guide#wp-limitations + + This means when a level is reloaded before GC runs, both the old and new instances + of a spawned actor can exist simultaneously on the level. To avoid respawning + duplicates during RestoreLevel, we pre-populate PersistentObjectsByGuid with + already-existing actors. If a SpawnedActor entry has a GUID that's already in the map, + we skip respawning it, the existing instance will be restored instead. + */ for (AActor* Actor : Level->Actors) { if (!Actor) continue;