From 86575dd5bcb7876bfcacc1611ce04c74580da91d Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 29 Jun 2026 20:07:27 -0700 Subject: [PATCH] Fix co-op join disconnect: gate client go-in-game on subscene load A remote/cold client added NetworkStreamInGame the instant it got a NetworkId, before the gameplay subscene's ghost prefabs finished streaming. The server's first ghost snapshot then hit a client that couldn't resolve those prefabs -> "ghost ... ENTITY_NOT_FOUND" -> forced disconnect ("nothing loads"). On loopback / fast LAN the go-in-game handshake beats the ~0.4s entity-subscene stream. The host never hit it (it shares the server's loaded subscene); every remote join did -- MPPM virtual players and real networked clients alike. GoInGameClientSystem now gates full-client go-in-game on the gameplay subscene being loaded (HasSingleton); thin clients skip the gate. Verified cross-process in a standalone build over loopback: join ghost-prefab errors 15->0, disconnects 1->0, client stays connected and resolves all server ghosts. Also adds a -mhost / -mjoin command-line hook to MainMenuController for headless co-op testing (drives WorldLauncher.StartSession, no UI). Co-Authored-By: Claude Opus 4.8 --- .../Client/Connection/GoInGameClientSystem.cs | 11 ++++++++ .../Scripts/Client/UI/MainMenuController.cs | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs b/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs index 4e7345cf2..bf974d33f 100644 --- a/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs +++ b/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs @@ -29,6 +29,17 @@ namespace ProjectM.Client [BurstCompile] public void OnUpdate(ref SystemState state) { + // A FULL client must not go in-game until the gameplay subscene's ghost prefabs have streamed in. + // Otherwise the server's first ghost snapshot arrives before the client can resolve those prefabs + // ("ghost ... ENTITY_NOT_FOUND" -> the server disconnects the connection -> "nothing loads"). On + // loopback / fast LAN the connect+go-in-game handshake easily beats the ~0.5s entity-subscene stream. + // PlayerSpawner is a subscene-baked singleton that co-loads with the ghost prefabs, so its presence + // is a sound "subscene ready" gate. Thin clients never instantiate ghosts (and don't stream the + // subscene), so they skip the gate and connect immediately. + bool isThinClient = (state.WorldUnmanaged.Flags & WorldFlags.GameThinClient) == WorldFlags.GameThinClient; + if (!isThinClient && !SystemAPI.HasSingleton()) + return; + var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (_, connection) in diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs index a7b1bb13d..759459b34 100644 --- a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs +++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs @@ -21,6 +21,7 @@ namespace ProjectM.Client VisualElement _howToPanel; TextField _ipField; Label _classLabel; + bool _autoStarted; void Awake() { @@ -32,10 +33,12 @@ namespace ProjectM.Client // The menu owns the cursor. UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; + _autoStarted = TryAutoStartFromCommandLine(); } void OnEnable() { + if (_autoStarted) return; // headless CLI co-op session; don't build the menu UI if (_doc == null) _doc = GetComponent(); var root = _doc.rootVisualElement; if (root == null) return; @@ -43,6 +46,28 @@ namespace ProjectM.Client BuildMain(root); } + /// + /// Dev/automation hook: a player launched with -mhost auto-hosts; -mjoin <ip> auto-joins + /// (ip optional, defaults to loopback). Lets two standalone builds form a co-op session headlessly for + /// testing — the same path the menu buttons use, no UI clicks. + /// No effect when neither arg is present. Returns true if a session was started. + /// + static bool TryAutoStartFromCommandLine() + { + var args = System.Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "-mhost") { WorldLauncher.StartSession(SessionMode.Host, "", false); return true; } + if (args[i] == "-mjoin") + { + string ip = (i + 1 < args.Length && !args[i + 1].StartsWith("-")) ? args[i + 1] : "127.0.0.1"; + WorldLauncher.StartSession(SessionMode.Join, ip, false); + return true; + } + } + return false; + } + static void EnsureMenuWorld() { var w = World.DefaultGameObjectInjectionWorld;