UE 5.7 Seamless Travel + Listen Server Crash Report

Hi, there. I’ve encountered a seriously issue with UE5.7.4 engine in Multiplayer project. I’ll post a report below which is about the whole issue include phenomenon and underneath code.

Any suggestion or reply is appreciate, thank you.

Here it the report:

UE 5.7 Seamless Travel + Listen Server Crash Report (World.cpp:7116)

Engine Version: UE 5.7 (built from source, no local patches)
Project Environment: macOS / Windows, native C++ project,
standalone runtime (i.e. outside of Editor PIE)


1. Summary (TL;DR)

When a Listen Server initiates UWorld::ServerTravel(URL, /*bAbsolute=*/true /*bSeamless=*/true)
with at least one remote client connected, the Server reliably crashes 2~5 seconds after
SeamlessTravel completes. The crash window matches exactly the time it takes the remote
client to finish its own SeamlessTravel and send back the NMT_PCSwap control-channel message.

The fault is always:

Engine/Source/Runtime/Engine/Private/World.cpp:7116
UWorld::NotifyControlMessage(UNetConnection*, uint8, FInBunch&)
    if (NetDriver->ServerConnection)   // ← NetDriver == nullptr
EXCEPTION_ACCESS_VIOLATION reading address 0x0000000000000108

0x108 is the offset of UNetDriver::ServerConnection inside UNetDriver, so the
fault address corresponds to a null this->NetDriver being dereferenced.
In other words: a control message is being dispatched into a UWorld whose
NetDriver field has been silently zeroed out
.


2. Repro Environment

Item Value
Engine version UE 5.7 (release branch)
Platform macOS / Windows (both reproduce)
Network mode Listen Server (player-hosted, not Dedicated Server)
Launch mode -game command line / Standalone packaged build → always crashes
Launch mode (control) Editor PIE (GIsEditor==true) → does not reproduce (see §6)
Subsystems involved OSS LAN session, control channel (NMT_PCSwap), FLevelCollection
Player Pawn ADefaultPawn (irrelevant to crash, mentioned only to show minimality)
Custom NetDriver / ReplicationGraph None
Custom UEngine subclass None (default UGameEngine)
GameMode::bUseSeamlessTravel true
Override of GetSeamlessTravelActorList / PostSeamlessTravel None

3. Repro Steps

  1. Minimal project: two maps, Lobby and Origin, two AGameMode subclasses
    (bUseSeamlessTravel = true).
  2. A trivial TransitionMap (just WorldSettings, GameModeBase).
  3. Launch the host with -game, host a LAN session via OSS,
    ServerTravel("Lobby?listen").
  4. Launch a client with -game, discover the LAN session and join. Lobby networking is fine.
  5. On the host, call World->ServerTravel("/Game/Map/Origin/Origin?GameExperience=...", true /*bAbsolute*/).
    Note that ?listen is not carried in the new URL (we expect the engine to retain
    the listen-server context across SeamlessTravel).
  6. Both ends complete SeamlessTravel; the host crashes when the remote client’s
    NMT_PCSwap arrives.

4. Crash Stack & Live Inspection

Crash site:

[Callstack] UWorld::NotifyControlMessage()
    [Engine/Source/Runtime/Engine/Private/World.cpp:7116]

void UWorld::NotifyControlMessage(UNetConnection* Connection, uint8 MessageType, FInBunch& Bunch)
{
    if (NetDriver->ServerConnection)   // 7116
    {
        check(Connection == NetDriver->ServerConnection);
        ...
    }

Incoming message: MessageType == 15NMT_PCSwap
(Engine/Source/Runtime/Engine/Public/Net/DataChannel.h:
DEFINE_CONTROL_CHANNEL_MESSAGE(PCSwap, 15, int32);,
“client tells server it has completed a swap of its Connection->Actor”).

Watch values captured at the crash breakpoint (entry of UWorld::NotifyControlMessage,
prior to the null deref):

Expression Value
this->GetName() Origin
this->NetDriver nullptr (root cause of the deref)
Connection->Driver valid pointer
Connection->Driver->Notify == this true
Connection->Driver->World == this (i.e. Origin)
Connection->OwningActor->GetClass()->GetName() CBInGamePlayerController (the new PC, swap finished)
Connection->OwningActor->GetWorld()->GetName() Origin
((APlayerController*)Connection->OwningActor)->PendingSwapConnection nullptr

There is a one-way break in the bidirectional pointer relationship:

NetDriver ──Notify/World──▶ Origin     (intact)
NetDriver ◀──NetDriver──── Origin      (nullptr — was zeroed in reverse)

UNetConnection::OwningActor, its world, and PendingSwapConnection are all
in their expected post-swap terminal state. The bug is not in the message
producer (the client) nor in the PC-swap protocol; it is that Origin->NetDriver
gets reverse-cleared to null
by some engine-side path after the legitimate
SeamlessTravel migration has happened.


5. Root-Cause Analysis (engine-side)

5.1 CopyWorldData migrates only two collection types

Engine/Source/Runtime/Engine/Private/World.cpp:8395 CopyWorldData:

const FLevelCollection* CurrentCollection      = CurrentWorld->FindCollectionByType(ELevelCollectionType::DynamicSourceLevels);
const FLevelCollection* CurrentStaticCollection= CurrentWorld->FindCollectionByType(ELevelCollectionType::StaticLevels);
const FLevelCollection* LoadedCollection       = LoadedWorld->FindCollectionByType (ELevelCollectionType::DynamicSourceLevels);
const FLevelCollection* LoadedStaticCollection = LoadedWorld->FindCollectionByType (ELevelCollectionType::StaticLevels);

if (CurrentCollection && LoadedCollection) {
    LoadedCollection->SetNetDriver(NetDriver);
    CurrentCollection->SetNetDriver(nullptr);
}
if (CurrentStaticCollection && LoadedStaticCollection) {
    LoadedStaticCollection->SetNetDriver(NetDriver);
    CurrentStaticCollection->SetNetDriver(nullptr);
}
LoadedWorld->SetNetDriver(NetDriver);
NetDriver->SetWorld(LoadedWorld);

But ELevelCollectionType actually has three values
(Engine/Source/Runtime/Engine/Classes/Engine/EngineTypes.h:4239):

enum class ELevelCollectionType : uint8
{
    DynamicSourceLevels,        // CopyWorldData ✓
    DynamicDuplicatedLevels,    // CopyWorldData ✗  (skipped)
    StaticLevels,               // CopyWorldData ✓
};

DynamicDuplicatedLevels is never touched by CopyWorldData. Its NetDriver
slot stays at the default value (nullptr) it was initialized with.

5.2 SetActiveLevelCollection reverse-overwrites World->NetDriver

Engine/Source/Runtime/Engine/Private/World.cpp:9776 UWorld::SetActiveLevelCollection:

ActiveLevelCollectionIndex = LevelCollectionIndex;
const FLevelCollection* const ActiveLevelCollection = GetActiveLevelCollection();
...
GameState     = ActiveLevelCollection->GetGameState();
NetDriver     = ActiveLevelCollection->GetNetDriver();   // ← reverse overwrite
DemoNetDriver = ActiveLevelCollection->GetDemoNetDriver();

That is, World->NetDriver is forcibly overwritten every time the active
LevelCollection is switched, with the per-collection NetDriver — including
nullptr if that collection never received one.

5.3 LevelCollection context switching during UWorld::Tick

Engine/Source/Runtime/Engine/Private/LevelTick.cppUWorld::Tick iterates over
LevelCollections and uses FScopedLevelCollectionContextSwitch to temporarily
make each one the “active” collection:

for (int32 LevelCollectionIndex = 0; LevelCollectionIndex < LevelCollections.Num(); ++LevelCollectionIndex)
{
    FScopedLevelCollectionContextSwitch LevelContext(LevelCollectionIndex, this);
    ...
}

The scope guard calls SetActiveLevelCollection on enter / exit. When the iteration
hits the DynamicDuplicatedLevels collection (whose NetDriver==nullptr),
World->NetDriver is overwritten to nullptr for the duration of that scope.
Nothing in the engine restores it before the following control-message dispatch.

Presumably the original design assumed that World->NetDriver reads would only
happen against the “main” collection’s value. But UWorld::NotifyControlMessage
is invoked from the NetDriver itself through Driver->Notify, and it has no
way of knowing which LevelCollection context it lands inside; once it dereferences
this->NetDriver, it is at the mercy of whatever the most recent
SetActiveLevelCollection wrote.

5.4 Conditions that produce the third collection

Engine/Source/Runtime/Engine/Private/World.cpp:8537 (post-SeamlessTravel):

if (!GIsEditor && !IsRunningDedicatedServer() && bSwitchedToDefaultMap)
{
    LoadedWorld->DuplicateRequestedLevels(LoadedWorld->GetOuter()->GetFName());
}

These three predicates form exactly the Listen-Server -game / packaged
configuration:

  • !GIsEditor: standalone process
  • !IsRunningDedicatedServer(): listen server (player-hosted)
  • bSwitchedToDefaultMap: SeamlessTravel just finished

DuplicateRequestedLevels may create a DynamicDuplicatedLevels collection.
That collection is not included in CopyWorldData’s migration and therefore
retains a nullptr NetDriver indefinitely.

5.5 Full causal chain

1. ServerTravel(URL, bSeamless=true) → FSeamlessTravelHandler.
2. CopyWorldData(Lobby → Origin):
   • Origin->NetDriver        = NetDriver  ✓
   • Driver->World/Notify     = Origin     ✓
   • migrates Dynamic + Static collection NetDrivers, **skips DynamicDuplicatedLevels**.
3. World.cpp:8537 — under -game listen server — calls DuplicateRequestedLevels.
   • Origin acquires a DynamicDuplicatedLevels collection with NetDriver = nullptr.
4. UWorld::Tick iterates LevelCollections; FScopedLevelCollectionContextSwitch
   activates the DynamicDuplicatedLevels collection.
   • SetActiveLevelCollection sets Origin->NetDriver = nullptr.
5. The remote client finishes its own SeamlessTravel and sends NMT_PCSwap.
6. The server's NetDriver dispatches → Driver->Notify->NotifyControlMessage(this=Origin).
7. World.cpp:7116 reads this->NetDriver->ServerConnection.
   • this->NetDriver == nullptr → read at offset 0x108 → CRASH.

6. Why Editor PIE Hides the Bug

Launch mode GIsEditor DuplicateRequestedLevels runs Crash
Editor PIE Host true No (short-circuited by !GIsEditor) ✗ Does not crash
-game Host false Yes ✓ Reproduces
Standalone packaged Host false Yes ✓ Reproduces

This matches §5.4 perfectly and is independent corroboration of the root cause.


7. Eliminated Hypotheses (project-side bisection)

Hypothesis Mitigation tested Result
Missing TransitionMap (engine falls back to /Temp/Untitled) Added explicit TransitionMap config Same crash, same address
Missing net.AllowPIESeamlessTravel Added [ConsoleVariables] net.AllowPIESeamlessTravel=1 Only affects PIE — irrelevant for -game
Custom UActorComponent::SetIsReplicatedByDefault(true) on PC subobject Disabled replication Same crash, same address
DOREPLIFETIME(FPrimaryAssetId) on GameState Removed Same crash, same address
Custom APlayerController subclass Inspected — has no business logic in path Unrelated
OwningActor / PendingSwapConnection mis-state Captured at break — all in expected terminal state Unrelated

The project’s own code is conclusively not the trigger.


8. NMT_PCSwap Is the Trigger, Not the Cause

NMT_PCSwap is simply the first post-SeamlessTravel control-channel message
that lands in UWorld::NotifyControlMessage on the server. The window during
which Origin->NetDriver is nullptr opens earlier than the NMT_PCSwap arrival,
but no other code path dereferences World->NetDriver during that window —
so the failure surfaces only when a control message arrives.

In other words, any control message reaching World::NotifyControlMessage
during the bad window will hit the same null deref. NMT_PCSwap just happens to
be the earliest one in practice.


9. Suggested Engine-side Fixes

We believe any one of the following would resolve the race. We list them in
order of how localized the change is. Selection should be left to Epic, since
Demo / Replay subsystems also share these data structures.

Fix A — Make CopyWorldData migrate every collection type

Replace the two hand-written (Dynamic, Static) migration blocks with an
exhaustive loop:

for (ELevelCollectionType Type : { ELevelCollectionType::DynamicSourceLevels,
                                   ELevelCollectionType::DynamicDuplicatedLevels,
                                   ELevelCollectionType::StaticLevels })
{
    auto* Cur    = CurrentWorld->FindCollectionByType(Type);
    auto* Loaded = LoadedWorld ->FindCollectionByType(Type);
    if (Cur && Loaded)
    {
        Loaded->SetNetDriver(NetDriver);
        Cur   ->SetNetDriver(nullptr);
    }
}

Or use for (FLevelCollection& C : LoadedWorld->GetLevelCollections()) for
forward-compatibility with future collection types.

Fix B — Populate NetDriver inside DuplicateRequestedLevels

When DuplicateRequestedLevels creates a DynamicDuplicatedLevels collection,
inherit the NetDriver / DemoNetDriver from the source dynamic collection so
the new collection is born ready.

Fix C — SetActiveLevelCollection should not write nullptr

If the active collection has no NetDriver of its own, do not reverse-overwrite
World->NetDriver = nullptr; preserve the previous value:

if (UNetDriver* NewND = ActiveLevelCollection->GetNetDriver())
{
    NetDriver = NewND;
}
// else: keep World->NetDriver as-is

This may interact with Demo recording semantics; please review.

Fix D — Defensive null check in UWorld::NotifyControlMessage

The most conservative back-stop: add a NetDriver != nullptr guard at line 7116
and either re-route through the live NetDriver of the current WorldContext,
or drop the message with a one-shot warning. This does not address the root
cause but it would prevent the hard crash for affected users.


10. Project-side Workaround

Until Epic ships a fix, our project has switched the relevant transitions to
hard travel:

  • LobbyGameMode::bUseSeamlessTravel = false for the Lobby→InGame transition;
  • Player data that previously rode on APlayerState across SeamlessTravel is now
    carried by URL options (?Name=, ?GameExperience=) and a UGameInstanceSubsystem
    cache keyed by FUniqueNetId;
  • Future InGame→InGame transitions (e.g. the “next floor” of a roguelike) will
    also use hard travel + explicit data carrying.

We chose not to ship a local engine patch, because Fixes A/B/C all involve
cross-system semantics best decided by Epic.


11. Contact / Attachments

We can provide additional artifacts on request:

  • Crash dumps (.dmp)
  • Full server-side log
  • Minimal repro project

Reference paths in our codebase:

  • Source/ChaosBilliards/GameFramework/GameMode/CBLobbyGameMode.cpp (ServerTravel call site)
  • Source/ChaosBilliards/GameFramework/GameMode/CBInGameGameMode.cpp (target GameMode)
  • Config/DefaultEngine.ini (GameMapsSettings.TransitionMap, [ConsoleVariables])

The full host-side log of the crash sequence is in compile error.txt,
lines 1170–1230 (from LobbyGameMode::OnStartGameExperienceLoaded to the final callstack).