Environment
- FishNet 4.6.12 (Unity package,
com.firstgeargames.fishnet)
- Unity 6000.4.10f1, Windows, IL2CPP dedicated server
- Multipass → WebTransport
- Connection-scoped scene loads (
LoadConnectionScenes), server stacks scenes with AllowStacking = true
Summary
A client can silently fail to load a connection scene when the server's scene handle happens to match the handle of an unrelated scene already loaded on the client. The scene is never loaded, no warning is raised, and the only downstream symptom is SceneId ... not found in SceneObjects for every networked scene object in it.
Because Unity scene handles are per-process instance IDs, whether this happens is luck of the draw on each server start. It appears and disappears on its own.
Mechanism
-
The server builds a handle-based lookup to target a specific stacked instance:
SceneLookupData lookupData = new SceneLookupData(instance.Handle);
SceneLoadData sld = new SceneLoadData(lookupData) { ... };
NetworkManager.SceneManager.LoadConnectionScenes(connection, sld);
-
SceneManager broadcasts that lookup to the client with the server's handle intact. This looks intentional — the client echoes it back so the server can match the connection to the right instance:
// Only the server needs to find scene handles to send to client. Client will send these back to the server.
and on the client side:
ClientScenesLoadedBroadcast msg = new()
{
SceneLookupDatas = sceneLoadData.SceneLookupDatas
};
-
But the client also resolves that handle locally. SceneLookupData.GetScene prefers Handle over Name:
if (Handle != 0)
{
result = SceneManager.GetScene(Handle);
if (!string.IsNullOrEmpty(result.name))
foundByHandle = true;
}
if (!foundByHandle)
result = SceneManager.GetScene(NameOnly, null, warnIfDuplicates);
and SceneManager.GetScene(int) just scans the client's own loaded scenes:
public static Scene GetScene(int sceneHandle)
{
int count = UnitySceneManager.sceneCount;
for (int i = 0; i < count; i++)
{
Scene s = UnitySceneManager.GetSceneAt(i);
if (s.handle == sceneHandle)
return s;
}
return new();
}
-
If that scan happens to match some unrelated client scene, CanLoadScene treats the requested scene as already loaded and, for a client, refuses outright:
bool alreadyLoaded = !string.IsNullOrEmpty(s.name);
if (alreadyLoaded)
{
// Only servers can load the same scene multiple times for stacking.
if (!qd.AsServer)
return false;
...
}
The lookup never reaches loadableScenes, so the scene processor is never asked to load anything.
Evidence
Client log, subscribing to OnLoadStart / OnLoadEnd:
OnSceneStartLoad: scenes=[StartScene A|-322]
OnSceneEndLoad: loaded=[] skipped=[ClientPreboot]
SceneId of 4480193996185786833 not found in SceneObjects (x3)
The skipped list is the giveaway. It is built only from names of scenes GetScene() actually resolved:
List<string> skippedScenes = requestedLoadScenes.ToList();
for (int i = 0; i < loadedScenes.Count; i++)
skippedScenes.Remove(loadedScenes[i].name);
The request was StartScene A. The resolved name was ClientPreboot — a completely unrelated bootstrap scene that happened to hold handle -322 in the client process.
Controlled confirmation
Restarting only the server, leaving the client process running and untouched, changed nothing but the handle the server sends:
|
Before |
After |
| Handle sent by server |
StartScene A|-322 |
StartScene A|-156 |
| Result |
loaded=[] skipped=[ClientPreboot] |
loaded=[StartScene A] skipped=[] |
SceneId not found errors |
3 |
0 |
Same client, same build, same scene — only a server-side handle number differed.
Impact
The client ends up in a world with none of that scene's networked objects and no terrain. Nothing is logged on either side to say the load was skipped; the SceneId not found errors point at scene/observer configuration, which sends you looking in the wrong place.
Suggested fix
On a client — but not a host, where handles are genuinely shared — resolve incoming scene lookups by Name and ignore the handle, while leaving the handle untouched in the data echoed back to the server. That would cover the two places a received lookup is resolved: the requestedLoadSceneNames loop in LoadScenes_Internal, and CanLoadScene.
Clients do not stack scenes, so name matching should be sufficient for them, and the server still gets its own handle back for instance matching.
Failing that, even just a warning when a lookup resolves by handle to a scene whose name does not match SceneLookupData.Name would turn this from a silent failure into an obvious one.
Note
This project uses a custom SceneProcessorBase (Addressables-backed) rather than DefaultSceneProcessor. That should not be relevant here: CanLoadScene rejects the lookup before anything is added to loadableScenes, so the processor is never invoked. The only local modification to SceneManager.cs is in processor selection during initialization; the load and lookup paths quoted above are unmodified 4.6.12.
Environment
com.firstgeargames.fishnet)LoadConnectionScenes), server stacks scenes withAllowStacking = trueSummary
A client can silently fail to load a connection scene when the server's scene handle happens to match the handle of an unrelated scene already loaded on the client. The scene is never loaded, no warning is raised, and the only downstream symptom is
SceneId ... not found in SceneObjectsfor every networked scene object in it.Because Unity scene handles are per-process instance IDs, whether this happens is luck of the draw on each server start. It appears and disappears on its own.
Mechanism
The server builds a handle-based lookup to target a specific stacked instance:
SceneManagerbroadcasts that lookup to the client with the server's handle intact. This looks intentional — the client echoes it back so the server can match the connection to the right instance:// Only the server needs to find scene handles to send to client. Client will send these back to the server.and on the client side:
But the client also resolves that handle locally.
SceneLookupData.GetSceneprefersHandleoverName:and
SceneManager.GetScene(int)just scans the client's own loaded scenes:If that scan happens to match some unrelated client scene,
CanLoadScenetreats the requested scene as already loaded and, for a client, refuses outright:The lookup never reaches
loadableScenes, so the scene processor is never asked to load anything.Evidence
Client log, subscribing to
OnLoadStart/OnLoadEnd:The
skippedlist is the giveaway. It is built only from names of scenesGetScene()actually resolved:The request was
StartScene A. The resolved name wasClientPreboot— a completely unrelated bootstrap scene that happened to hold handle-322in the client process.Controlled confirmation
Restarting only the server, leaving the client process running and untouched, changed nothing but the handle the server sends:
StartScene A|-322StartScene A|-156loaded=[] skipped=[ClientPreboot]loaded=[StartScene A] skipped=[]SceneId not founderrorsSame client, same build, same scene — only a server-side handle number differed.
Impact
The client ends up in a world with none of that scene's networked objects and no terrain. Nothing is logged on either side to say the load was skipped; the
SceneId not founderrors point at scene/observer configuration, which sends you looking in the wrong place.Suggested fix
On a client — but not a host, where handles are genuinely shared — resolve incoming scene lookups by
Nameand ignore the handle, while leaving the handle untouched in the data echoed back to the server. That would cover the two places a received lookup is resolved: therequestedLoadSceneNamesloop inLoadScenes_Internal, andCanLoadScene.Clients do not stack scenes, so name matching should be sufficient for them, and the server still gets its own handle back for instance matching.
Failing that, even just a warning when a lookup resolves by handle to a scene whose name does not match
SceneLookupData.Namewould turn this from a silent failure into an obvious one.Note
This project uses a custom
SceneProcessorBase(Addressables-backed) rather thanDefaultSceneProcessor. That should not be relevant here:CanLoadScenerejects the lookup before anything is added toloadableScenes, so the processor is never invoked. The only local modification toSceneManager.csis in processor selection during initialization; the load and lookup paths quoted above are unmodified 4.6.12.