diff --git a/AdvancedCoop/CoopAdvancedHardening.cs b/AdvancedCoop/CoopAdvancedHardening.cs index 0ee0e40..ba6b1f2 100644 --- a/AdvancedCoop/CoopAdvancedHardening.cs +++ b/AdvancedCoop/CoopAdvancedHardening.cs @@ -65,7 +65,7 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) void IOnFrameUpdate.OnFrameUpdate(double dt) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) { _wasConnected = false; @@ -77,7 +77,7 @@ void IOnFrameUpdate.OnFrameUpdate(double dt) { _nextLobbyHeartbeatTicks = now + SecondsToTicks(LobbyHeartbeatSeconds); SendLobbyHeartbeat(net); - GameMenu.RefreshRoomStatusMenuIfVisible(); + LobbySession.RefreshRoomStatusMenuIfVisible(); } if (_nextProgressSyncTicks == 0 || now >= _nextProgressSyncTicks) @@ -106,8 +106,8 @@ private static void SendLobbyHeartbeat(NetNode net) try { var level = ModEntry.me?._level?.map?.id?.ToString() ?? ModEntry.Instance?.levelId ?? string.Empty; - var seed = GameMenu.TryGetKnownSeed(out var knownSeed) ? knownSeed : 0; - net.SendLobbyState(GameMenu.Username, level, seed, GetLocalPermanentProgressSignature()); + var seed = LobbySession.TryGetKnownSeed(out var knownSeed) ? knownSeed : 0; + net.SendLobbyState(LobbySession.Username, level, seed, GetLocalPermanentProgressSignature()); } catch (Exception ex) { @@ -194,8 +194,8 @@ private static void PushConnectionHudStatus(NetNode net) if (!_connectedHudMessageShown || (net.IsHost && remoteCount > System.Math.Max(0, previousRemoteCount))) { var status = net.IsHost - ? string.Format(CultureInfo.CurrentCulture, GameMenu.Localize("Co-op: {0} friend(s) connected"), remoteCount) - : GameMenu.Localize("Co-op: connected to host"); + ? string.Format(CultureInfo.CurrentCulture, LobbySession.Localize("Co-op: {0} friend(s) connected"), remoteCount) + : LobbySession.Localize("Co-op: connected to host"); MultiplayerUI.PushSystemMessage(status, 4.0, 1.0); _connectedHudMessageShown = true; } @@ -222,7 +222,7 @@ public static void ReceiveLobbyState(string payload) var name = parts[nameIndex].Trim(); if (name.Length > 64) name = name[..64]; - GameMenu.ReceiveRemoteUsername(name); + LobbySession.ReceiveRemoteUsername(name); } } catch (Exception ex) @@ -259,7 +259,7 @@ public static void ReceiveRuneProgress(string payload) // mobility/rune state is visible, recreating the old "same seed but only same save works" // race. The coalesced action is also safe when no User exists yet; the normal frame/hero // retry keeps the pending items until one becomes available. - GameMenu.EnqueueCriticalMainThreadCoalesced( + MainThreadPump.EnqueueCriticalMainThreadCoalesced( "coop:apply-host-progress", ApplyPendingPermanentProgress); } @@ -307,7 +307,7 @@ private static void ApplyPendingPermanentProgress() { _lastAppliedProgress = sig; MultiplayerUI.PushSystemMessage( - string.Format(CultureInfo.CurrentCulture, GameMenu.Localize("Co-op progression synced: +{0} unlock(s)"), added), + string.Format(CultureInfo.CurrentCulture, LobbySession.Localize("Co-op progression synced: +{0} unlock(s)"), added), 6.0, 1.5); _log?.Information("[CoopAdvanced] Applied {Count} synced permanent unlocks", added); diff --git a/FakeDeath/FakeDeath.cs b/FakeDeath/FakeDeath.cs index 0966b12..12346da 100644 --- a/FakeDeath/FakeDeath.cs +++ b/FakeDeath/FakeDeath.cs @@ -972,7 +972,7 @@ private void ProcessReviveHold(NetNode net) return; } - var isHoldPressed = GameMenu.IsReviveHoldInputDown(me); + var isHoldPressed = ReviveInput.IsReviveHoldInputDown(me); if (!isHoldPressed) { @@ -1304,7 +1304,7 @@ private void HandleAllPlayersDowned(NetNode net) return; _allDownedRestartQueued = true; - GameMenu.QueueHostRestartFromDeath("all_players_downed"); + LobbySession.QueueHostRestartFromDeath("all_players_downed"); } private void ShowAllDownedGameOverLogo() diff --git a/GameDataSync/GameDataSync.LevelGraph.cs b/GameDataSync/GameDataSync.LevelGraph.cs index f0f9fd5..a5405eb 100644 --- a/GameDataSync/GameDataSync.LevelGraph.cs +++ b/GameDataSync/GameDataSync.LevelGraph.cs @@ -157,7 +157,7 @@ public static void ReceiveLevelGraph(string payload) // Lobby auto-start waits on HasPendingRemoteLevelGraph; re-arm if seed/exec // already arrived before this LGRAPH. - GameMenu.NotifyClientLaunchPrerequisiteProgress(); + LobbySession.NotifyClientLaunchPrerequisiteProgress(); var reason = LevelReloadReason.GraphUpdated; // Graph and boss-rune packets can arrive in either order. Fold a pending boss-rune @@ -403,7 +403,7 @@ private static bool TryWaitGetRemoteLevelGraph(string levelId, int timeoutMs, ou // committing a locally generated layout. if (now >= nextRequestAt) { - try { GameMenu.NetRef?.RequestLevelGraph(levelId); } catch { } + try { LobbySession.NetRef?.RequestLevelGraph(levelId); } catch { } nextRequestAt = now + 1000; } diff --git a/GameDataSync/GameDataSync.LevelGraphCapture.cs b/GameDataSync/GameDataSync.LevelGraphCapture.cs index 8fae56a..bf4863b 100644 --- a/GameDataSync/GameDataSync.LevelGraphCapture.cs +++ b/GameDataSync/GameDataSync.LevelGraphCapture.cs @@ -59,17 +59,25 @@ internal partial class GameDataSync private static void TryCaptureLevelGraphNode(object? candidate, LevelGraphSync sync, HashSet seenUids) { - if (candidate is not RoomNode node) - return; + // One unsupported node property (e.g. a generated proxy method missing in this game + // build) must never abort the whole graph capture. Skip the node, keep the topology. + try + { + if (candidate is not RoomNode node) + return; - var nodeSync = CaptureLevelGraphNode(node); - if (nodeSync == null || string.IsNullOrWhiteSpace(nodeSync.Uid)) - return; + var nodeSync = CaptureLevelGraphNode(node); + if (nodeSync == null || string.IsNullOrWhiteSpace(nodeSync.Uid)) + return; - if (!seenUids.Add(nodeSync.Uid)) - return; + if (!seenUids.Add(nodeSync.Uid)) + return; - sync.Nodes.Add(nodeSync); + sync.Nodes.Add(nodeSync); + } + catch + { + } } private static LevelGraphNodeSync? CaptureLevelGraphNode(RoomNode node) @@ -113,7 +121,7 @@ private static void TryCaptureLevelGraphNode(object? candidate, LevelGraphSync s ZChildrenUids = CaptureRoomNodeUids(node.zChildren), Npcs = CaptureNpcIds(node.npcs), ZLinks = CaptureZLinks(node.zLinks), - GenData = CaptureLevelGraphGenData(node.genData) + GenData = TryCaptureLevelGraphGenData(node) }; } @@ -290,6 +298,27 @@ private static List CaptureZLinks(ArrayObj? zLinks) return hasAny ? result : null; } + /// + /// is a generated proxy virtual; a game build that renamed or + /// dropped get_genData() makes the plain property access throw "Method not found" + /// BEFORE 's dynamic reads ever run, which aborted the + /// entire graph capture and silently starved the client of the authoritative layout (host + /// logged "Failed to send level graph", client logged WORLD DESYNC + abort). The captured + /// GenData is informational only (the apply path copies the client's own local genData), so + /// a failed read must degrade to null, never kill the capture. + /// + private static LevelGraphGenDataSync? TryCaptureLevelGraphGenData(RoomNode node) + { + try + { + return CaptureLevelGraphGenData(node.genData); + } + catch + { + return null; + } + } + private static LevelGraphZDoorTypeSync? CaptureZDoorType(ZDoorType? zDoorType) { if (zDoorType is null) diff --git a/GameDataSync/GameDataSync.LevelGraphReload.cs b/GameDataSync/GameDataSync.LevelGraphReload.cs index e81dc20..bb93acc 100644 --- a/GameDataSync/GameDataSync.LevelGraphReload.cs +++ b/GameDataSync/GameDataSync.LevelGraphReload.cs @@ -102,7 +102,7 @@ private static void ScheduleLevelReload(string levelId, LevelReloadReason reason if (string.IsNullOrWhiteSpace(levelId) || reason == LevelReloadReason.None) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.IsHost) return; @@ -118,7 +118,7 @@ private static void ScheduleLevelReload(string levelId, LevelReloadReason reason _pendingLevelReloadPayloads[levelId] = null; } - GameMenu.EnqueueMainThreadCoalesced("level:reload:" + levelId, () => + MainThreadPump.EnqueueMainThreadCoalesced("level:reload:" + levelId, () => { try { @@ -143,7 +143,7 @@ private static bool ShouldSuppressClientLevelReload() { if (ModEntry.IsLocalPlayerDowned()) return true; - if (GameMenu.IsClientRestartPending()) + if (LobbySession.IsClientRestartPending()) return true; } catch @@ -175,7 +175,7 @@ private static void TryTriggerLevelReload(string graphLevelId) _pendingLevelReloadPayloads.Remove(graphLevelId); } - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.IsHost) return; diff --git a/GameDataSync/GameDataSync.cs b/GameDataSync/GameDataSync.cs index 6765104..efae063 100644 --- a/GameDataSync/GameDataSync.cs +++ b/GameDataSync/GameDataSync.cs @@ -125,11 +125,11 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // 2) the native Restart flow after the old world has already been disposed. // // Case (2) is the important restart crash guard: by the time User.newGame is called, - // GameMenu._inActualRun may already be false even though RunLaunchCoordinator still + // LobbySession._inActualRun may already be false even though RunLaunchCoordinator still // owns the committed run in LoadingLevel/Playing. Generating a fresh seed there used // to attempt a second RUNCOMMIT and throw "Cannot commit a host launch while session // phase is LoadingLevel", taking down the host and force-disconnecting the client. - var currentNet = GameMenu.NetRef; + var currentNet = LobbySession.NetRef; // A Boss Rush entry is ALSO a newGame raised from inside a live run, so it looks // identical to a nested/sublevel re-entry to the checks below. It is not: the door @@ -138,8 +138,8 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // door never progresses because the peers are on different runs. var bossRushLaunchPending = IsBossRushLaunchKind(GetLaunchKind(gdata)) || - (currentNet?.IsHost == true && GameMenu.HasPrecommittedHostBossRushLaunch()) || - (currentNet != null && !currentNet.IsHost && GameMenu.HasPendingRemoteBossRushLaunch()); + (currentNet?.IsHost == true && LobbySession.HasPrecommittedHostBossRushLaunch()) || + (currentNet != null && !currentNet.IsHost && LobbySession.HasPendingRemoteBossRushLaunch()); if (!sameRunRestart && !bossRushLaunchPending && @@ -147,7 +147,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, currentNet.IsAlive && gdata is LaunchMode.NewGame) { - if (GameMenu.IsInActualRun()) + if (LobbySession.IsInActualRun()) { _log?.Information( "[NetMod][RunLaunch] Bypassing nested User.newGame launch sync inside active run seed={Seed}", @@ -175,7 +175,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, { try { - // Manual/native Restart can bypass GameMenu.QueueHostRestartFromDeath. + // Manual/native Restart can bypass LobbySession.QueueHostRestartFromDeath. // Fence the old generated world here as well so the same level id cannot // replay the previous run's seed/graph to a client during the restart. currentNet.ClearCachedGeneratedLevelStateForRestart(); @@ -201,7 +201,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, effectiveStreamEnabled = ResolveCurrentRunStreamEnabled(); effectiveLaunch = new LaunchMode.NewGame(effectiveCustomMode, effectiveStreamEnabled); } - else if (GameMenu.TryGetAuthoritativePendingNewGameLaunch(out var selectedCustom, out var selectedStreamEnabled)) + else if (LobbySession.TryGetAuthoritativePendingNewGameLaunch(out var selectedCustom, out var selectedStreamEnabled)) { effectiveCustomMode = selectedCustom; effectiveStreamEnabled = selectedStreamEnabled; @@ -231,13 +231,13 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, _lastHeroSkinSyncPayload = null; _lastHeroHeadSkinSyncNet = null; _lastHeroHeadSkinSyncPayload = null; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var launchKind = GetLaunchKind(gdata); var nativeBossRushLaunch = IsBossRushLaunchKind(launchKind); var expectedBossRushLaunch = !sameRunRestart && (nativeBossRushLaunch || - (net?.IsHost == true && GameMenu.HasPrecommittedHostBossRushLaunch()) || - (net != null && !net.IsHost && GameMenu.HasPendingRemoteBossRushLaunch())); + (net?.IsHost == true && LobbySession.HasPrecommittedHostBossRushLaunch()) || + (net != null && !net.IsHost && LobbySession.HasPendingRemoteBossRushLaunch())); if (expectedBossRushLaunch && !nativeBossRushLaunch) { // Some generated bindings expose the Boss Rush launch variant with a runtime name @@ -258,7 +258,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, Seed = restartSeed; } else if (shouldSynchronizeSeed && - GameMenu.TryConsumePrecommittedHostRunSeed(launchKind, out var precommittedSeed, out var precommittedSequence)) + LobbySession.TryConsumePrecommittedHostRunSeed(launchKind, out var precommittedSeed, out var precommittedSequence)) { Seed = precommittedSeed; seedSequence = precommittedSequence; @@ -271,7 +271,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, } else if (shouldSynchronizeSeed && ShouldGenerateFreshHostSeed(gdata)) { - Seed = GameMenu.ForceGenerateServerSeed("NewGame_hook"); + Seed = LobbySession.ForceGenerateServerSeed("NewGame_hook"); } else { @@ -283,7 +283,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, if (shouldSynchronizeSeed) { if (!reusedPrecommittedSeed) - seedSequence = GameMenu.RegisterHostRunSeed(Seed, launchKind, "user.newGame"); + seedSequence = LobbySession.RegisterHostRunSeed(Seed, launchKind, "user.newGame"); // Commit/ACK/execute is now the authoritative launch barrier. The legacy seed // remains as a migration/debug packet, but cannot make a v0.8.90 client load by itself. @@ -294,7 +294,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // client. Degrade to an un-synchronized local launch and say so loudly instead. try { - GameMenu.CommitHostRunLaunchFromHook(Seed, seedSequence, launchKind); + LobbySession.CommitHostRunLaunchFromHook(Seed, seedSequence, launchKind); } catch (Exception ex) { @@ -305,7 +305,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, launchKind, ex.Message); DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI.MultiplayerUI.PushSystemMessage( - GameMenu.Localize("Run failed to synchronize with your friend. Please return to the menu and retry."), + LobbySession.Localize("Run failed to synchronize with your friend. Please return to the menu and retry."), 8.0, 1.0); } @@ -313,7 +313,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // Resending the same precommitted sequence is intentional: it refreshes the // host cache and covers a client that completed its handshake during the intro. net.SendSeed(seedSequence, Seed, launchKind); - GameMenu.MarkRunLaunchLoading(seedSequence, $"host_user.newGame:{launchKind}"); + LobbySession.MarkRunLaunchLoading(seedSequence, $"host_user.newGame:{launchKind}"); } } else if (net != null) @@ -323,10 +323,10 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, Seed = restartSeed; } else if (shouldSynchronizeSeed && - GameMenu.TryConsumeNextRemoteRunSeed(out var remoteSeed, out var remoteSequence, out var remoteLaunchKind)) + LobbySession.TryConsumeNextRemoteRunSeed(out var remoteSeed, out var remoteSequence, out var remoteLaunchKind)) { Seed = remoteSeed; - GameMenu.MarkRunLaunchLoading(remoteSequence, $"client_user.newGame:{remoteLaunchKind}"); + LobbySession.MarkRunLaunchLoading(remoteSequence, $"client_user.newGame:{remoteLaunchKind}"); if (!string.IsNullOrWhiteSpace(remoteLaunchKind) && !string.Equals(remoteLaunchKind, launchKind, StringComparison.Ordinal)) { @@ -337,7 +337,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, launchKind); } } - else if (GameMenu.TryGetPendingRemoteBossRushSeed(out var authoritativeBossRushSeed)) + else if (LobbySession.TryGetPendingRemoteBossRushSeed(out var authoritativeBossRushSeed)) { // Authoritative host seed already received but not consumed via the coordinator // (e.g. arrived a frame late). This is still the host's seed, not a local one. @@ -349,7 +349,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, else if (shouldSynchronizeSeed) { // Protocol 17: never silently generate a local Boss Rush / run on the client. The - // launch gate (GameMenu.TryBeginLocalBossRushLoad / structured auto-start) is meant + // launch gate (LobbySession.TryBeginLocalBossRushLoad / structured auto-start) is meant // to guarantee the authoritative seed is present before newGame runs, so reaching // here is a hard desync. Keep the native seed only as an unavoidable last resort and // surface it loudly instead of quietly diverging. @@ -359,7 +359,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, expectedBossRushLaunch, lvl); DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI.MultiplayerUI.PushSystemMessage( - GameMenu.Localize("Run failed to synchronize with your friend. Please return to the menu and retry."), + LobbySession.Localize("Run failed to synchronize with your friend. Please return to the menu and retry."), 8.0, 1.0); } @@ -408,7 +408,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // Custom Mode GameData ctor calls CustomGameData.checkIntegrity(user) which // immediately touches user.itemMeta. Main.getGame loads User via Save.tryLoad, // so TitleScreen prep alone is not enough on the client auto-start path. - if (!GameMenu.PrepareUserForCustomModeLaunch(self)) + if (!LobbySession.PrepareUserForCustomModeLaunch(self)) { _log?.Warning( "[NetMod] Custom Mode newGame: User.itemMeta could not be prepared (role={Role})", @@ -422,7 +422,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, } finally { - GameMenu.ClearAuthoritativePendingNewGameLaunch(); + LobbySession.ClearAuthoritativePendingNewGameLaunch(); if (sameRunRestart) ClearSameRunRestart(); } @@ -436,7 +436,7 @@ private static bool ShouldSynchronizeRunSeed(LaunchMode? launch) // shared seed each side fights different encounters. The historical double-load race // this gate used to prevent came from the client-side reconcile restart firing on an // unconsumed nested seed; that restart is now suppressed for Boss Rush kinds in - // GameMenu.ReceiveHostRunSeed, so sharing the seed is safe. Challenge rooms, daily + // RunLaunchFlow.ReceiveHostRunSeed, so sharing the seed is safe. Challenge rooms, daily // modes and other nested launches stay local. lock (_sameRunRestartSync) { @@ -643,7 +643,7 @@ public static void RestoreRemoteUserData(User user) public static void TriggerRemoteDeath() { - GameMenu.EnqueueCriticalMainThreadCoalesced("game:remote-death", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:remote-death", () => { if (ModEntry.IsLocalPlayerDowned()) return; @@ -783,7 +783,7 @@ public static bool TryWaitApplyRemoteLevelSeed(string levelId, Rand rng, int tim if (now >= nextRequestAt) { - try { GameMenu.NetRef?.RequestLevelSeed(levelId); } catch { } + try { LobbySession.NetRef?.RequestLevelSeed(levelId); } catch { } nextRequestAt = now + 1000; } @@ -801,7 +801,7 @@ public static void SendLevelSeed(string levelId, Rand rng, NetNode? net) if (net == null || !net.IsAlive || rng == null || string.IsNullOrWhiteSpace(levelId)) return; - GameMenu.PublishInitialLevelSeed(levelId, rng.seed, net); + LobbySession.PublishInitialLevelSeed(levelId, rng.seed, net); SendSerializerSync(net); net.SendLevelSeed(levelId, rng.seed); } @@ -965,10 +965,10 @@ public void Dispose() if (serializerClass == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var canRestorePrevious = _restorePrevious && - GameMenu.CurrentRole == NetRole.Client && + LobbySession.CurrentRole == NetRole.Client && net != null && net.IsAlive && !net.IsHost && @@ -1032,9 +1032,9 @@ public static LocalSerializerSaveScope BeginLocalSerializerSaveScope(string reas if (previousSeq == _localSerializerSeq && previousUid == _localSerializerUid) return default; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var restorePrevious = - GameMenu.CurrentRole == NetRole.Client && + LobbySession.CurrentRole == NetRole.Client && net != null && net.IsAlive && !net.IsHost && @@ -1045,7 +1045,7 @@ public static LocalSerializerSaveScope BeginLocalSerializerSaveScope(string reas _log?.Information( "[NetMod][Save] serializer scope begin reason={Reason} role={Role} fromSeq={FromSeq} fromUid={FromUid} toSeq={ToSeq} toUid={ToUid}", reason, - GameMenu.CurrentRole, + LobbySession.CurrentRole, previousSeq, previousUid, _localSerializerSeq, @@ -1145,12 +1145,12 @@ public static void ReceiveBossRune(string payload) } _hasRemoteBossRune = true; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net != null && net.IsHost) return; // Lobby auto-start waits on HasRemoteBossRune; re-arm if seed/exec already arrived. - GameMenu.NotifyClientLaunchPrerequisiteProgress(); + LobbySession.NotifyClientLaunchPrerequisiteProgress(); RequestBossRuneHudRefresh(bossRune); @@ -1161,7 +1161,7 @@ public static void ReceiveBossRune(string payload) MarkPendingBossRuneReload(bossRune); // _log?.Information("[NetMod] Received remote boss rune {BossRune}", bossRune); - GameMenu.EnqueueCriticalMainThreadCoalesced("game:boss-rune-apply", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:boss-rune-apply", () => { try { @@ -1180,7 +1180,7 @@ public static void ReceiveBossRune(string payload) // applying the value so the coalesced, throttled reload path can run once both exist. try { - var n = GameMenu.NetRef; + var n = LobbySession.NetRef; if (n != null && n.IsAlive && !n.IsHost) TryScheduleBossRuneReloadForCurrentLevel(); } @@ -1406,7 +1406,7 @@ internal static void RequestBossRuneHudRefreshFromRemoteState() private static void RequestBossRuneHudRefresh(int bossRune) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.IsHost) return; @@ -1421,7 +1421,7 @@ private static void RequestBossRuneHudRefresh(int bossRune) internal static void PumpBossRuneHudRefresh() { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.IsHost) { ClearBossRuneHudRefresh(); diff --git a/Interaction/InteractionSync.Doors.cs b/Interaction/InteractionSync.Doors.cs index 71faf4b..b79e2dc 100644 --- a/Interaction/InteractionSync.Doors.cs +++ b/Interaction/InteractionSync.Doors.cs @@ -22,7 +22,7 @@ private void Hook_Door_init(Hook_Door.orig_init orig, Door self) return; _doorStableAnchors[self] = ComputeDoorStableAnchor(self); - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net != null && net.IsAlive) { _doorHadAutoClose[self] = SafeRead(() => self.autoClose, false); @@ -61,7 +61,7 @@ private void TrySendDoorEvent(Door self, string action) { if (_applyingRemoteDoorEvents) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsNetReadyForSend(net)) return; try @@ -238,7 +238,7 @@ private void ApplyRemoteDoorEvents(List events) _applyingRemoteDoorEvents = true; try { - var localId = GameMenu.NetRef?.id ?? 0; + var localId = LobbySession.NetRef?.id ?? 0; foreach (var ev in events) { if (ev.UserId == localId) diff --git a/Interaction/InteractionSync.Elevators.cs b/Interaction/InteractionSync.Elevators.cs index 0dc0af1..1f2a69b 100644 --- a/Interaction/InteractionSync.Elevators.cs +++ b/Interaction/InteractionSync.Elevators.cs @@ -22,7 +22,7 @@ private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) orig(self); if (_applyingRemoteElevatorEvents || _applyingRemoteElevatorStateEvents) return; - if (!IsNetReadyForSend(GameMenu.NetRef)) + if (!IsNetReadyForSend(LobbySession.NetRef)) return; try { @@ -31,7 +31,7 @@ private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) return; _elevatorLastInterSendTickMs[self] = now; - var net = GameMenu.NetRef!; + var net = LobbySession.NetRef!; var (x, y) = GetElevatorStableAnchor(self); var sequence = ++_nextElevatorSequence; var levelId = GetCurrentInteractionLevelId(); @@ -80,7 +80,7 @@ private void ApplyRemoteElevatorEvents(List events) if (level == null || events == null || events.Count == 0) return; - var localId = GameMenu.NetRef?.id ?? 0; + var localId = LobbySession.NetRef?.id ?? 0; _applyingRemoteElevatorEvents = true; try { @@ -135,7 +135,7 @@ private void ApplyRemoteElevatorStateEvents(List events if (level == null || events == null || events.Count == 0) return; - var localId = GameMenu.NetRef?.id ?? 0; + var localId = LobbySession.NetRef?.id ?? 0; _applyingRemoteElevatorStateEvents = true; try { diff --git a/Interaction/InteractionSync.Misc.cs b/Interaction/InteractionSync.Misc.cs index 4a6e0d4..221e281 100644 --- a/Interaction/InteractionSync.Misc.cs +++ b/Interaction/InteractionSync.Misc.cs @@ -17,7 +17,7 @@ public partial class InteractionSync { private bool Hook_SwitchBossRune_canBeActivated(Hook_SwitchBossRune.orig_canBeActivated orig, SwitchBossRune self, Hero by) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if(net != null && !net.IsHost) return false; return orig(self, by); @@ -27,7 +27,7 @@ private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, Swit { orig(self); - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsNetReadyForSend(net) || !net!.IsHost) return; @@ -48,7 +48,7 @@ private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, Swit private void Hook_SwitchBossRune_updateCells(Hook_SwitchBossRune.orig_updateCells orig, SwitchBossRune self, bool add) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; // updateCells performs a native main-level rebuild. Dispose the old remote render shells // before that rebuild starts; otherwise Boot.tryRender can visit a GhostKing whose sprite @@ -87,7 +87,7 @@ private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, Treasure private void TrySendTreasureChestEvent(TreasureChest self) { - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTreasureChest(x, y, GetCurrentInteractionLevelId()), "TreasureChest"); + TrySendInteractEvent(self, (x, y) => LobbySession.NetRef!.SendInterTreasureChest(x, y, GetCurrentInteractionLevelId()), "TreasureChest"); } private void Hook_VineLadder_activate(Hook_VineLadder.orig_activate orig, VineLadder self) @@ -102,7 +102,7 @@ private void TrySendVineLadderEvent(VineLadder self) { if (_applyingRemoteVineLadderEvents) return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterVineLadder(x, y, GetCurrentInteractionLevelId()), "VineLadder"); + TrySendInteractEvent(self, (x, y) => LobbySession.NetRef!.SendInterVineLadder(x, y, GetCurrentInteractionLevelId()), "VineLadder"); } private void Hook_Teleport_open(Hook_Teleport.orig_open orig, Teleport self) @@ -117,7 +117,7 @@ private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround orig(self, x, y); if (_applyingRemoteBreakableGroundEvents) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsNetReadyForSend(net) || ModEntry.me == null || !ReferenceEquals(self, ModEntry.me)) return; try @@ -134,7 +134,7 @@ private void TrySendTeleportEvent(Teleport self) { if (_applyingRemoteTeleportEvents) return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTeleport(x, y, GetCurrentInteractionLevelId()), "Teleport"); + TrySendInteractEvent(self, (x, y) => LobbySession.NetRef!.SendInterTeleport(x, y, GetCurrentInteractionLevelId()), "Teleport"); } private void Hook_Portal_show(Hook_Portal.orig_show orig, Portal self) @@ -153,12 +153,12 @@ private void TrySendPortalEvent(Portal self, string action) { if (_applyingRemotePortalEvents) return; - if (!IsNetReadyForSend(GameMenu.NetRef)) + if (!IsNetReadyForSend(LobbySession.NetRef)) return; try { var (x, y) = GetEntityPixelPos(self); - GameMenu.NetRef!.SendInterPortal(x, y, action, GetCurrentInteractionLevelId()); + LobbySession.NetRef!.SendInterPortal(x, y, action, GetCurrentInteractionLevelId()); } catch (Exception ex) { diff --git a/Interaction/InteractionSync.PersistentState.cs b/Interaction/InteractionSync.PersistentState.cs index 7d625d9..e614470 100644 --- a/Interaction/InteractionSync.PersistentState.cs +++ b/Interaction/InteractionSync.PersistentState.cs @@ -80,7 +80,7 @@ private void RememberHostLatchedVineLadder(VineLadder? vineLadder) { if (vineLadder == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || !net.IsHost) return; _hostActivatedVineLadders.Add(vineLadder); @@ -90,7 +90,7 @@ private void RememberHostLatchedTeleport(Teleport? teleport) { if (teleport == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || !net.IsHost) return; _hostOpenedTeleports.Add(teleport); diff --git a/Interaction/InteractionSync.Plates.cs b/Interaction/InteractionSync.Plates.cs index 806c0c7..e94fd43 100644 --- a/Interaction/InteractionSync.Plates.cs +++ b/Interaction/InteractionSync.Plates.cs @@ -67,7 +67,7 @@ private void TrySendActivatorEvent(Entity self, string logContext) if (_applyingRemotePressurePlateEvents) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsNetReadyForSend(net)) return; @@ -90,7 +90,7 @@ private void ApplyRemotePressurePlateEvents(List events var localHeroTyped = ModEntry.me; var localHero = localHeroTyped as Entity; - var localId = GameMenu.NetRef?.id ?? 0; + var localId = LobbySession.NetRef?.id ?? 0; if (localHero == null || localHeroTyped == null) return; diff --git a/Interaction/InteractionSync.cs b/Interaction/InteractionSync.cs index 5395f1c..473bad0 100644 --- a/Interaction/InteractionSync.cs +++ b/Interaction/InteractionSync.cs @@ -249,7 +249,7 @@ private static bool IsNetReadyForSend(NetNode? net) => private bool TrySendInteractEvent(Entity entity, Action send, string logContext) { - if (!IsNetReadyForSend(GameMenu.NetRef)) + if (!IsNetReadyForSend(LobbySession.NetRef)) return false; try { @@ -266,7 +266,7 @@ private bool TrySendInteractEvent(Entity entity, Action send, st void IOnHeroUpdate.OnHeroUpdate(double dt) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) return; diff --git a/LaunchSync/RunLaunchCoordinator.cs b/LaunchSync/RunLaunchCoordinator.cs index 4c64c4d..8dc3afa 100644 --- a/LaunchSync/RunLaunchCoordinator.cs +++ b/LaunchSync/RunLaunchCoordinator.cs @@ -109,7 +109,7 @@ internal static RunLaunchDescriptor CreateOrReuseHostDescriptor( // live host NetNode. Throwing here would escape into Dead Cells' native User.newGame // hook, which has no handler - the host process dies mid-launch and force-drops the // client. Adopt the role instead; only a genuine host/client contradiction still throws. - if (_role == NetRole.None && GameMenu.NetRef is { IsAlive: true, IsHost: true }) + if (_role == NetRole.None && LobbySession.NetRef is { IsAlive: true, IsHost: true }) { ResetLocked(NetRole.Host, "adopt_host_role_for_local_launch"); _log?.Information("[NetMod][RunLaunch] Adopted host role for a local launch commit"); @@ -349,7 +349,7 @@ internal static void ReceiveHostAck(RunLaunchAck ack) // Removed: WaitForHostAck / WaitForClientQueued. // // Both were blocking rendezvous helpers with no call sites. Launch synchronization is now - // non-blocking end to end: the host publishes and keeps re-publishing (GameMenu's launch + // non-blocking end to end: the host publishes and keeps re-publishing (LobbySession's launch // beacon) while the client arms itself from received state, so nothing needs to park a thread // waiting for a peer. Keeping unused blocking waits around invited exactly the main-thread // stall this design exists to avoid. @@ -609,6 +609,11 @@ internal static bool HasExecutableRemoteLaunch(int sequence) /// same-run restart re-entry into User.newGame from a genuinely new lobby launch. /// A restart must reuse the committed seed and must never try to commit a second /// RUNCOMMIT while the original session is LoadingLevel/Playing. + /// + /// LaunchCommitted must NOT count here: that is the pre-first-load client state + /// after RUNCOMMIT/RUNEXEC. Treating it as an active run made the client's first + /// User.newGame look like a native restart, skip seed consumption, and later + /// force a false unconsumed_host_launch restart that regenerated the world. /// internal static bool TryGetActiveRunSeedForNativeRestart(out int seed, out CoopSessionPhase phase) { @@ -617,10 +622,8 @@ internal static bool TryGetActiveRunSeedForNativeRestart(out int seed, out CoopS phase = _state.Phase; var descriptor = _role == NetRole.Host ? _hostDescriptor : _remoteDescriptor; if (descriptor != null && - _state.Phase is (CoopSessionPhase.LaunchCommitted or - CoopSessionPhase.LoadingLevel or - CoopSessionPhase.Playing or - CoopSessionPhase.TransitionCommitted)) + _state.Phase is (CoopSessionPhase.LoadingLevel or + CoopSessionPhase.Playing)) { seed = descriptor.RunSeed; return seed > 0; @@ -967,7 +970,7 @@ private static bool TryTransitionLocked(CoopSessionPhase next, string reason) _state.Phase, sequence, reason); - DeadCellsMultiplayerMod.GameMenu.NotifyRunLaunchPhaseForSaveGuard(_state.Phase.ToString()); + DeadCellsMultiplayerMod.SaveGuard.NotifyRunLaunchPhaseForSaveGuard(_state.Phase.ToString()); return true; } diff --git a/Mobs/Bosses/BeholderArenaSync.cs b/Mobs/Bosses/BeholderArenaSync.cs index b85906a..e38223e 100644 --- a/Mobs/Bosses/BeholderArenaSync.cs +++ b/Mobs/Bosses/BeholderArenaSync.cs @@ -77,7 +77,7 @@ private static bool Hook_Beholder_canBeHitBy(Hook_Beholder.orig_canBeHitBy orig, if (self == null) return native; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) return native; @@ -106,7 +106,7 @@ private static void Hook_Beholder_setPlatformsState(Hook_Beholder.orig_setPlatfo if (self == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) return; @@ -141,7 +141,7 @@ internal static void ApplyState(Mob mob, bool? vulnerable, bool? platformsUp) if (mob is not Beholder beholder) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.IsHost) return; diff --git a/Mobs/Bosses/BossHpScaling.cs b/Mobs/Bosses/BossHpScaling.cs index 6d1bc73..1b31163 100644 --- a/Mobs/Bosses/BossHpScaling.cs +++ b/Mobs/Bosses/BossHpScaling.cs @@ -23,7 +23,7 @@ public static void ScaleForMultiplayer(Mob mob) // The host owns authoritative mob HP. Scaling client proxies locally can briefly create a // different max-life value and can multiply the same boss more than once before the first // host snapshot arrives. - var net = DeadCellsMultiplayerMod.GameMenu.NetRef; + var net = DeadCellsMultiplayerMod.LobbySession.NetRef; if (net == null || !net.IsAlive || !net.IsHost) return; diff --git a/Mobs/MobSyncTrace.cs b/Mobs/MobSyncTrace.cs index 34594c3..933d698 100644 --- a/Mobs/MobSyncTrace.cs +++ b/Mobs/MobSyncTrace.cs @@ -465,6 +465,12 @@ public static void LogDeferredMobRegistration(string role, string levelId, strin public static void LogStaleTrackedMapping(int syncId, int localIndex, string reason) { + var now = Environment.TickCount64; + var key = "stale|" + (reason ?? string.Empty); + if (s_mappingMismatchLastTickByKey.TryGetValue(key, out var last) && now - last < 1000) + return; + s_mappingMismatchLastTickByKey[key] = now; + Log.Warning( "[MobSync] stale tracked sync mapping syncId={SyncId} localIndex={LocalIndex} reason={Reason}", syncId, @@ -474,6 +480,13 @@ public static void LogStaleTrackedMapping(int syncId, int localIndex, string rea private static long s_lastNetworkDrainBurstLogTick; + /// + /// Last-tick per (context|reason) for the warning spam throttles below. ConcurrentDictionary + /// so bursts of the same mapping failure collapse to ~1 line/sec instead of one line/frame. + /// + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_mappingMismatchLastTickByKey = + new(); + /// /// Logs when the reliable protocol queue backed up enough to trigger burst draining — the /// condition that, before the adaptive pump, stalled the receive loop and froze client @@ -646,6 +659,12 @@ public static void LogIncomingMappingMismatch( string actualType, string reason) { + var now = Environment.TickCount64; + var key = "mismatch|" + (context ?? string.Empty) + "|" + (reason ?? string.Empty); + if (s_mappingMismatchLastTickByKey.TryGetValue(key, out var last) && now - last < 1000) + return; + s_mappingMismatchLastTickByKey[key] = now; + Log.Warning( "[MobSync] mapping mismatch context={Context} syncId={SyncId} expectedType={ExpectedType} actualType={ActualType} reason={Reason}", context ?? string.Empty, diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index 20f9b82..649e1a4 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -33,7 +33,7 @@ private static void TrySendHostMobAttack(Mob mob, string skillId, bool requiresT if (mob == null || string.IsNullOrWhiteSpace(skillId)) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsHost(net)) return; @@ -132,7 +132,7 @@ private static bool RebuildMobArray(Level? level) var candidateIdentityToken = ComputeLevelIdentityToken(level); var candidateEntityCount = 0; var candidateTrackedMobs = new List(); - var role = MobSyncNetRoleForTrace(GameMenu.NetRef); + var role = MobSyncNetRoleForTrace(LobbySession.NetRef); var levelId = GetLevelTraceIdSafe(level); var levelKey = GetLevelRuntimeKey(level); if (level?.entities != null) @@ -273,14 +273,31 @@ private static bool RebuildMobArray(Level? level) s_batchMobsScratch.AddRange(trackedMobs); } + var minSyncId = -1; + var maxSyncId = -1; + var registryCount = 0; + lock (Sync) + { + foreach (var pair in MobToId) + { + if (pair.Value <= 0) + continue; + registryCount++; + if (minSyncId < 0 || pair.Value < minSyncId) + minSyncId = pair.Value; + if (pair.Value > maxSyncId) + maxSyncId = pair.Value; + } + } + MobSyncTrace.LogRegistryRebuild( role, levelId, trackedBeforeReset, trackedAfterRebuild, - trackedMobs.Count, - trackedMobs.Count > 0 ? 0 : -1, - trackedMobs.Count > 0 ? trackedMobs.Count - 1 : -1, + registryCount, + minSyncId, + maxSyncId, nextRuntimeSyncId, generationAfterRebuild, s_levelIdentityToken); @@ -289,7 +306,7 @@ private static bool RebuildMobArray(Level? level) levelId, levelKey, trackedAfterRebuild, - trackedMobs.Count, + registryCount, generationAfterRebuild, s_levelIdentityToken); @@ -510,7 +527,7 @@ private static int AddTrackedMobLocked(Mob mob) // Clients must not append an unbound transient proxy wrapper. Their sync ids come // from the level registry/host; adding a wrapper with no id bloats trackedMobs and can // later displace the canonical entry. Hosts may allocate ids for runtime spawns. - if (syncId < 0 && GameMenu.NetRef?.IsHost != true) + if (syncId < 0 && LobbySession.NetRef?.IsHost != true) return -1; trackedMobs.Add(mob); @@ -534,7 +551,7 @@ private static void ResetMobTrackingLocked(string reason) s_lastResetTrackedCount = trackedMobs.Count; MobSyncTrace.LogTrackingReset( s_lastResetReason, - MobSyncNetRoleForTrace(GameMenu.NetRef), + MobSyncNetRoleForTrace(LobbySession.NetRef), GetLevelTraceIdSafe(currentLevel), GetLevelRuntimeKey(currentLevel), trackedMobs.Count, @@ -573,7 +590,7 @@ internal static void ResetForFullGameDispose(string reason) s_levelIdentityGeneration = 0; } - try { GameMenu.NetRef?.ClearMobSyncQueues(); } catch { } + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } } private static void ResetMobTrackingStateLocked() @@ -950,28 +967,96 @@ private static int FindTrackedMobIndexLocked(Mob mob) // HaxeProxy can expose another managed wrapper for the same native mob. Match it by // current level, runtime class and near-identical position. Unlike Mob.__uid, this does - // not alias every enemy of the same class. Reject ambiguous overlapping matches. + // not alias every enemy of the same class. + // + // Crowded packs used to return -1 on any ambiguity, which made TryGetMobSyncId mint a + // SECOND NetId for the same native enemy (registry_duplicate_twin / missing_sync_id). + // Prefer an already-mapped candidate; only reject when several unmapped equals collide. var matchedIndex = -1; + var matchedSyncId = int.MaxValue; + var matchedHasId = false; + var unmappedEquals = 0; for (int i = 0; i < trackedMobs.Count; i++) { var candidate = trackedMobs[i]; if (!AreLikelySameNativeMobProxy(candidate, mob)) continue; - if (matchedIndex >= 0) - return -1; - matchedIndex = i; + var hasId = false; + var candidateSyncId = 0; + if (candidate != null && MobToId.TryGetValue(candidate, out candidateSyncId) && candidateSyncId > 0) + hasId = true; + if (hasId) + { + if (!matchedHasId || candidateSyncId < matchedSyncId) + { + matchedIndex = i; + matchedSyncId = candidateSyncId; + matchedHasId = true; + } + continue; + } + + if (!matchedHasId) + { + unmappedEquals++; + if (matchedIndex < 0) + matchedIndex = i; + } } - if (matchedIndex >= 0) + if (matchedIndex < 0) + return -1; + + // Multiple unmapped equals and no mapped owner — refuse rather than guess. + if (!matchedHasId && unmappedEquals > 1) + return -1; + + var canonical = trackedMobs[matchedIndex]; + if (canonical != null) + trackedMobIndices[canonical] = matchedIndex; + return matchedIndex; + } + + /// + /// Host-only: if any already-mapped tracked wrapper is the same native enemy, reuse that + /// NetId instead of minting a duplicate. Caller holds . + /// + private static bool TryAttachAliasToMappedNativeProxyLocked(Mob mob, out int syncId) + { + syncId = -1; + if (mob == null) + return false; + + Mob? best = null; + var bestSyncId = int.MaxValue; + for (var i = 0; i < trackedMobs.Count; i++) { - var canonical = trackedMobs[matchedIndex]; - if (canonical != null) - trackedMobIndices[canonical] = matchedIndex; - return matchedIndex; + var candidate = trackedMobs[i]; + if (candidate == null || ReferenceEquals(candidate, mob)) + continue; + if (!MobToId.TryGetValue(candidate, out var candidateSyncId) || candidateSyncId <= 0) + continue; + if (!AreLikelySameNativeMobProxy(candidate, mob)) + continue; + if (candidateSyncId >= bestSyncId) + continue; + + best = candidate; + bestSyncId = candidateSyncId; } - return -1; + if (best == null || bestSyncId == int.MaxValue) + return false; + + syncId = bestSyncId; + s_mobSyncAliases.Remove(mob); + s_mobSyncAliases.Add(mob, new MobSyncAlias + { + SyncId = syncId, + Generation = s_levelIdentityGeneration + }); + return true; } private static bool AreLikelySameNativeMobProxy(Mob? left, Mob? right) @@ -998,7 +1083,15 @@ private static bool AreLikelySameNativeMobProxy(Mob? left, Mob? right) var dx = GetWorldX(left) - GetWorldX(right); var dy = GetWorldY(left) - GetWorldY(right); - return double.IsFinite(dx) && double.IsFinite(dy) && dx * dx + dy * dy <= 0.25; + if (double.IsFinite(dx) && double.IsFinite(dy) && dx * dx + dy * dy <= PixelsPerCase * PixelsPerCase) + return true; + + // Pixel distance alone can rule out two copies of the SAME native enemy when the + // peers' coordinates drift by a fraction of a tile (spawn snapping, gravity + // landing, interpolation: 1000.0 vs 1000.1). Same-cell is the tie-breaker. + GetMobWorldCells(left, out var lcx, out var lcy); + GetMobWorldCells(right, out var rcx, out var rcy); + return lcx == rcx && lcy == rcy; } catch { @@ -1112,6 +1205,80 @@ private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) if (!MobToId.TryGetValue(mappedMob, out var mappedSyncId) || mappedSyncId != syncId) { + // Two failure modes share this branch: + // * the reverse mapping (MobToId) was lost while the forward entry survived, or + // * the same enemy was rebound to a different id — host wrapper/proxy duplication + // hands one native mob two NetIds, so IdToMob[syncId] points at a mob whose + // MobToId now names another id. + // The old remove-and-fail turned both into dropped states and lost client damage, + // which is exactly the registry_mismatch / missing_sync_id churn. Heal instead. + + // If another live tracked mob genuinely owns this id, repair the forward entry to + // point at it (the previous owner is a stale wrapper of the same native enemy). + Mob? trueOwner = null; + var ownerCount = 0; + for (var i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null || ReferenceEquals(candidate, mappedMob)) + continue; + if (MobToId.TryGetValue(candidate, out var candidateId) && + candidateId == syncId && + IsStateRebindCandidateLocked(candidate)) + { + trueOwner = candidate; + ownerCount++; + if (ownerCount > 1) + break; + } + } + + if (ownerCount == 1 && trueOwner != null) + { + IdToMob[syncId] = trueOwner; + s_trackedMobValidationPending = true; + MobSyncTrace.LogBindSyncId( + "registry_mismatch_repaired", + syncId, + BuildMobStateTypeSignature(trueOwner), + GetWorldX(trueOwner), + GetWorldY(trueOwner)); + mob = trueOwner; + return true; + } + + // The forward entry points at a mob that now owns a different id — usually a + // duplicate NetId minted for a second HaxeProxy wrapper of the same native enemy. + // Keep BOTH forward ids resolving to the canonical wrapper so peer hits addressed + // to the duplicate id still land. Dropping IdToMob[dup] was the missing_sync_id + // path for legitimate high syncIds after registry_alias. + if (IsStateRebindCandidateLocked(mappedMob)) + { + if (mappedSyncId <= 0) + { + IdToMob[syncId] = mappedMob; + MobToId[mappedMob] = syncId; + } + else + { + // Canonical reverse mapping stays on mappedSyncId; duplicate syncId only + // keeps a forward alias so old packets/hits still resolve. + IdToMob[syncId] = mappedMob; + IdToMob[mappedSyncId] = mappedMob; + MobToId[mappedMob] = mappedSyncId; + } + + s_trackedMobValidationPending = true; + MobSyncTrace.LogIncomingMappingMismatch( + "registry_alias", + syncId, + BuildMobStateTypeSignature(mappedMob), + string.Empty, + mappedSyncId > 0 ? $"aliased_to:{mappedSyncId}" : "reverse_mapping_lost"); + mob = mappedMob; + return true; + } + MobSyncTrace.LogStaleTrackedMapping( syncId, localIndex, @@ -1367,9 +1534,17 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return false; // Clients never invent NetIds — host is the sole authority (native ids diverge). - if (GameMenu.NetRef?.IsHost != true) + if (LobbySession.NetRef?.IsHost != true) return false; + // Never mint a second NetId for another HaxeProxy wrapper of the same native enemy. + // Duplicate ids are what produce registry_duplicate_twin on the client and then + // missing_sync_id hits once the host drops the alias forward map. + if (TryAttachAliasToMappedNativeProxyLocked(mob, out syncId)) + return true; + + if (nextRuntimeSyncId < 1) + nextRuntimeSyncId = 1; syncId = nextRuntimeSyncId++; MobToId[mob] = syncId; IdToMob[syncId] = mob; @@ -1377,7 +1552,7 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) // Dynamic/runtime-spawned mobs must be in the canonical tracked list immediately; // otherwise the first dirty packet creates an IdToMob entry that is rejected as // untracked_mob on the next dequeue. - if (FindTrackedMobIndexLocked(mob) < 0) + if (FindExactTrackedMobIndexLocked(mob) < 0) { trackedMobs.Add(mob); trackedMobIndices[mob] = trackedMobs.Count - 1; @@ -1643,21 +1818,47 @@ private static bool IsBossRelatedEntity(string? type) lowerType.Contains("ttcl"); } - /// Rounds world coordinates to int32 pixels so host/client hit routing agrees despite float drift. - private static void QuantizeWorldPositionToPixelsInt32(double x, double y, out int qx, out int qy) + /// + /// Quantizes world coordinates to integer CELLS (floor(world / PixelsPerCase)). A mob's + /// cell is stable across host/client even when the peers' pixel coordinates drift by a + /// fraction of a tile (spawn snapping, gravity landing, interpolation: 1000.0 vs 1000.1 + /// both live in the same cell), so cells are the correct granularity for cross-peer + /// identity/position matching. The old int32-pixel quantization flipped at a 0.5px + /// boundary, which is far below the real host/client coordinate divergence. + /// + private static void QuantizeWorldPositionToCells(double x, double y, out int cx, out int cy) { if (!double.IsFinite(x) || !double.IsFinite(y)) { - qx = 0; - qy = 0; + cx = 0; + cy = 0; return; } const double lim = int.MaxValue - 8; - var rx = System.Math.Clamp(System.Math.Round(x, MidpointRounding.AwayFromZero), -lim, lim); - var ry = System.Math.Clamp(System.Math.Round(y, MidpointRounding.AwayFromZero), -lim, lim); - qx = (int)rx; - qy = (int)ry; + cx = (int)System.Math.Clamp(System.Math.Floor(x / PixelsPerCase), -lim, lim); + cy = (int)System.Math.Clamp(System.Math.Floor(y / PixelsPerCase), -lim, lim); + } + + /// Computes a mob's integer cell from its live world position. + private static void GetMobWorldCells(Mob? mob, out int cx, out int cy) + { + if (mob == null) + { + cx = 0; + cy = 0; + return; + } + + try + { + QuantizeWorldPositionToCells(GetWorldX(mob), GetWorldY(mob), out cx, out cy); + } + catch + { + cx = 0; + cy = 0; + } } private static Mob? ResolveTrackedMobForIncomingAttackLocked(NetNode.MobAttack attack) @@ -1796,7 +1997,9 @@ private static bool TryResolveBossForIncomingAttackLocked( private static void TryRebindTrackedMobSyncIdLocked(Mob mob, int syncId) { - if (mob == null || syncId < 0) + // NetId 0 is reserved / retired. Rebinding onto 0 reintroduces the syncId=0 type thrash + // seen on PrisonCourtyard (host ghost-echo + client state mismatch cascade). + if (mob == null || syncId <= 0) return; if (!IsLevelIdentityReadyLocked(mob._level)) @@ -2563,7 +2766,7 @@ private static void RefreshHostContactAttackState(Mob mob) if (mob == null) return; - var currentTargetUserId = ResolveHostTargetUserId(ResolveCurrentHostPlayerCombatTarget(mob), GameMenu.NetRef?.id ?? 0); + var currentTargetUserId = ResolveHostTargetUserId(ResolveCurrentHostPlayerCombatTarget(mob), LobbySession.NetRef?.id ?? 0); lock (Sync) { if (currentTargetUserId <= 0) @@ -2644,7 +2847,7 @@ private static int ResolveHostTargetUserId(Entity? target, int localUserId) private static Entity? ResolveHostPlayerCombatEntity(int userId) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsHost(net) || userId <= 0) return null; diff --git a/Mobs/MonsterSynchronization.AuthoritativeDeath.cs b/Mobs/MonsterSynchronization.AuthoritativeDeath.cs index a158a32..c83b78a 100644 --- a/Mobs/MonsterSynchronization.AuthoritativeDeath.cs +++ b/Mobs/MonsterSynchronization.AuthoritativeDeath.cs @@ -149,7 +149,7 @@ private static void FlushHostDeathTombstoneResends(NetNode net) private static void FlushPendingClientAuthoritativeDeaths() { - if (!IsClient(GameMenu.NetRef)) + if (!IsClient(LobbySession.NetRef)) return; var pending = new List(); diff --git a/Mobs/MonsterSynchronization.BossDeathWatchdog.cs b/Mobs/MonsterSynchronization.BossDeathWatchdog.cs index fa7e77b..ce8a3ac 100644 --- a/Mobs/MonsterSynchronization.BossDeathWatchdog.cs +++ b/Mobs/MonsterSynchronization.BossDeathWatchdog.cs @@ -80,7 +80,7 @@ private sealed class UnresolvedBossDie /// private static void MarkClientBossAuthoritativeZeroLife(Mob? mob) { - if (mob == null || !IsClient(GameMenu.NetRef)) + if (mob == null || !IsClient(LobbySession.NetRef)) return; if (!BossSyncHelpers.IsBossEncounterCombatant(mob)) return; @@ -165,7 +165,7 @@ private static void RememberUnresolvedBossDieLocked(NetNode.MobDie die) /// private static void ProcessClientBossDeathWatchdog() { - if (!IsClient(GameMenu.NetRef)) + if (!IsClient(LobbySession.NetRef)) return; if (!TryGetCurrentLevelIdentityToken(out var identityToken)) return; diff --git a/Mobs/MonsterSynchronization.BossIdentity.cs b/Mobs/MonsterSynchronization.BossIdentity.cs index c890d64..f021e8c 100644 --- a/Mobs/MonsterSynchronization.BossIdentity.cs +++ b/Mobs/MonsterSynchronization.BossIdentity.cs @@ -52,7 +52,7 @@ private sealed class HostBossIdentity /// internal static int GetOrAssignHostBossEntityId(Mob mob) { - if (mob == null || !IsHost(GameMenu.NetRef)) + if (mob == null || !IsHost(LobbySession.NetRef)) return 0; try @@ -161,7 +161,7 @@ private static string SafeBossType(Mob mob) internal static bool TryGetHostBossPhaseSuccessor(Mob mob, out Mob successor) { successor = null!; - if (mob == null || !IsHost(GameMenu.NetRef)) + if (mob == null || !IsHost(LobbySession.NetRef)) return false; try diff --git a/Mobs/MonsterSynchronization.ClientApply.cs b/Mobs/MonsterSynchronization.ClientApply.cs index 863df5e..eade472 100644 --- a/Mobs/MonsterSynchronization.ClientApply.cs +++ b/Mobs/MonsterSynchronization.ClientApply.cs @@ -645,7 +645,7 @@ private static bool TryDeferCulledClientMobDeath(Mob mob) { if (mob == null) return false; - if (IsHost(GameMenu.NetRef)) + if (IsHost(LobbySession.NetRef)) return false; // Only defer truly culled/sleeping mobs. Visible or locally awake mobs can run the diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index ca51b9d..c84f6dd 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -660,7 +660,7 @@ private static void ApplyAuthoritativeAffectState(int mobSyncId, Mob mob, string // Affects run vanilla calls (setAffectS etc.) on the mob; never do that on a mob // culled locally on a client (same .cx hazard class as culled deaths/attacks). // Checked BEFORE the dedupe cache so the payload re-applies once the mob wakes. - if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + if (!IsHost(LobbySession.NetRef) && IsMobCulledLocally(mob)) return; lock (Sync) @@ -1069,7 +1069,7 @@ private static void ProcessClientMobAttackIntent(Mob mob, ClientMobAttackIntent // the culled-death .cx fatal). A locally-culled mob is far from the local hero, so // the replay is off-screen and its target is out of reach here anyway; position/life // still sync via state snapshots. - if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + if (!IsHost(LobbySession.NetRef) && IsMobCulledLocally(mob)) { MobSyncTrace.LogClientAttackRoute("skipped_culled_" + traceRoute, traceSyncId, skillId); return; @@ -1751,7 +1751,7 @@ private static bool TryResolveClientDirectPlayerCombatTarget(Mob mob, int target if (mob == null || targetUserId <= 0 || !IsMobHostileToPlayers(mob)) return false; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var localId = net?.id ?? 0; if (localId <= 0) return false; @@ -1979,7 +1979,7 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) // local kill), corrupting their death state - the source of the level-transition // render fatal. The deferred flush skips mobs that finish destroying themselves // and only completes genuinely stuck ghosts. - if (!IsHost(GameMenu.NetRef) && !BossSyncHelpers.IsBossMob(mob) && TryDeferCulledClientMobDeath(mob)) + if (!IsHost(LobbySession.NetRef) && !BossSyncHelpers.IsBossMob(mob) && TryDeferCulledClientMobDeath(mob)) continue; TryWakeMobForForcedSimulation(mob); @@ -2044,7 +2044,7 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int if (start < 0 || end > hits.Count) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var isHost = IsHost(net); s_pendingMobHitAppliesScratch.Clear(); var rejectedGeneration = 0; @@ -2389,7 +2389,7 @@ private static void TryReplayIncomingSpecialHitReaction(Mob mob, double damageHi // Cosmetic replay only: skip it on clients for mobs culled locally. Running vanilla // hit resolution on a sleeping, never-initialized mob is hazardous, and the reaction // is off-screen anyway. The authoritative life still arrives via state snapshots. - if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + if (!IsHost(LobbySession.NetRef) && IsMobCulledLocally(mob)) return; try @@ -2400,7 +2400,7 @@ private static void TryReplayIncomingSpecialHitReaction(Mob mob, double damageHi ? System.Math.Clamp(damageHint, 1.0, System.Math.Max(1.0, GetMobLifeOrFallback(mob, 1) * 8.0)) : 1.0; Hero? replaySourceHero = null; - if (IsHost(GameMenu.NetRef)) + if (IsHost(LobbySession.NetRef)) { replaySourceHero = ModEntry.me ?? ModCore.Modules.Game.Instance?.HeroInstance; try @@ -2446,7 +2446,7 @@ private static void TryWakeMobForForcedSimulation(Mob mob) // The wake is required on the authoritative HOST (it must simulate mobs that remote // players are fighting). On clients it only served cosmetic hit/death replays; waking // a locally culled mob there runs vanilla behavior logic on uninitialized state and crashes. - if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + if (!IsHost(LobbySession.NetRef) && IsMobCulledLocally(mob)) return; PromoteMobToSyncVisibleState(mob); @@ -2457,7 +2457,7 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) if (mob == null) return false; - var userId = ResolveHostTargetUserId(target ?? ResolveCurrentHostPlayerCombatTarget(mob), GameMenu.NetRef?.id ?? 0); + var userId = ResolveHostTargetUserId(target ?? ResolveCurrentHostPlayerCombatTarget(mob), LobbySession.NetRef?.id ?? 0); if (userId <= 0) return false; @@ -2580,7 +2580,7 @@ private static void RequestAuthoritativeHitReconcileLocked(Mob? mob, int syncId, { if (mob == null || syncId < 0) return; - if (!IsHost(GameMenu.NetRef)) + if (!IsHost(LobbySession.NetRef)) return; var now = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -2605,9 +2605,11 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo uniqueMob = null; candidateCount = 0; - if (!IsHost(GameMenu.NetRef)) + if (!IsHost(LobbySession.NetRef)) return false; - if (hit.MobIndex < 0) + // Fence retired/sentinel ids. Rebinding onto 0 (or an id the host never issued) is what + // reintroduced Spinner/Worm/Hurler ownership under syncId=0 on PrisonCourtyard. + if (hit.MobIndex <= 0 || hit.MobIndex >= nextRuntimeSyncId) return false; if (string.IsNullOrWhiteSpace(hit.Type)) return false; @@ -2622,7 +2624,10 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo var maxDistanceSq = MobHitMissingSyncIdRebindDistancePx * MobHitMissingSyncIdRebindDistancePx; var bestDistanceSq = double.MaxValue; var secondBestDistanceSq = double.MaxValue; + var bestCellExact = false; + var secondBestCellExact = false; Mob? bestMob = null; + QuantizeWorldPositionToCells(hit.X, hit.Y, out var hitCx, out var hitCy); for (int i = 0; i < entities.length; i++) { @@ -2633,10 +2638,17 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo if (!DoesMobMatchStateType(mob, hit.Type)) continue; - // Do not steal another valid sync id. This fallback is only for mobs that are - // alive locally but lost/unbound in the host registry. + // Do not steal a HEALTHY sync id bound to another living mob: that is a host + // wrapper duplicate of this enemy, still alive under its own id. This fallback + // only reclaims mobs that are alive locally but lost/unbound in the host registry — + // including a mob whose reverse mapping points at an id with no (or a stale) + // forward entry, which is orphaned garbage and safe to rebind. if (MobToId.TryGetValue(mob, out var existingSyncId) && existingSyncId != hit.MobIndex) - continue; + { + if (IdToMob.TryGetValue(existingSyncId, out var forwardMob) && + ReferenceEquals(forwardMob, mob)) + continue; + } double dx; double dy; @@ -2657,16 +2669,28 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo if (distanceSq > maxDistanceSq) continue; + GetMobWorldCells(mob, out var mobCx, out var mobCy); + var cellExact = mobCx == hitCx && mobCy == hitCy; + candidateCount++; - if (distanceSq < bestDistanceSq) + var prefer = bestMob == null || + (cellExact && !bestCellExact) || + (cellExact == bestCellExact && distanceSq < bestDistanceSq); + if (prefer) { - secondBestDistanceSq = bestDistanceSq; + if (bestMob != null) + { + secondBestDistanceSq = bestDistanceSq; + secondBestCellExact = bestCellExact; + } bestDistanceSq = distanceSq; + bestCellExact = cellExact; bestMob = mob; } - else if (distanceSq < secondBestDistanceSq) + else if (cellExact == bestCellExact && distanceSq < secondBestDistanceSq) { secondBestDistanceSq = distanceSq; + secondBestCellExact = cellExact; } } @@ -2675,6 +2699,7 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo if (candidateCount > 1 && secondBestDistanceSq < double.MaxValue && + secondBestCellExact == bestCellExact && System.Math.Sqrt(secondBestDistanceSq) - System.Math.Sqrt(bestDistanceSq) < MobFallbackMinimumScoreGap) { lock (Sync) @@ -2707,6 +2732,11 @@ private static bool TryResolveHostMissingHitMobLocked(NetNode.MobHit hit, out Mo /// private static void RecordGhostHitMissLocked(NetNode.MobHit hit) { + // Never ghost-echo NetId 0 / negative: 0 is reserved and re-echoing it is what + // re-seeded the courtyard syncId=0 type thrash after the real owner was gone. + if (hit.MobIndex <= 0) + return; + if (s_ghostHitMissGeneration != hit.Generation) { s_ghostHitMissBySyncId.Clear(); @@ -2793,8 +2823,8 @@ private static bool MobHitRegistryTypeMatchesLocked(Mob? registryMob, NetNode.Mo private static bool MobHitQuantizedPositionCloseEnoughLocked(Mob mob, NetNode.MobHit hit) { - QuantizeWorldPositionToPixelsInt32(hit.X, hit.Y, out var hx, out var hy); - QuantizeWorldPositionToPixelsInt32(GetWorldX(mob), GetWorldY(mob), out var mx, out var my); + QuantizeWorldPositionToCells(hit.X, hit.Y, out var hx, out var hy); + GetMobWorldCells(mob, out var mx, out var my); return mx == hx && my == hy; } @@ -2803,8 +2833,8 @@ private static bool MobHitQuantizedFallbackPositionMatchesLocked(Mob mob, NetNod if (mob == null) return false; - QuantizeWorldPositionToPixelsInt32(hit.X, hit.Y, out var hx, out var hy); - QuantizeWorldPositionToPixelsInt32(GetWorldX(mob), GetWorldY(mob), out var mx, out var my); + QuantizeWorldPositionToCells(hit.X, hit.Y, out var hx, out var hy); + GetMobWorldCells(mob, out var mx, out var my); var grounded = true; try diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index ff0f577..eeb210e 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -95,7 +95,7 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) { if (mob == null || !IsSyncMob(mob)) return; - if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) + if (!TryGetMobSyncId(mob, out var syncId) || syncId <= 0) return; double x; @@ -117,15 +117,47 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) } var visibleForSync = IsMobOnScreenForSync(mob); - var animPayload = visibleForSync ? BuildAnimPayload(mob) : string.Empty; + + // Skip heavy state-payload builds for idle off-screen mobs. Anim still rebuilds whenever + // the mob is visible so in-place attack cycles keep syncing. PrisonCourtyard (~100 + // tracked) previously built BOTH payloads every postUpdate — main-thread stall, idle CPU. + HostMobObservedState previous; + var hasPrevious = false; + lock (Sync) + { + hasPrevious = hostObservedMobStatesBySyncId.TryGetValue(syncId, out previous); + } + + var needsAnim = visibleForSync; + var needsStatePayload = true; + if (hasPrevious) + { + var lifeChanged = life != previous.Life || maxLife != previous.MaxLife; + var visibilityChanged = visibleForSync != previous.VisibleForSync; + var moveChanged = visibleForSync && ( + !previous.VisibleForSync || + !IsApproximatelyEqual(previous.X, x, MobStatePositionEpsilon) || + !IsApproximatelyEqual(previous.Y, y, MobStatePositionEpsilon) || + previous.Dir != dir); + + // Off-screen and unchanged: reuse last state payload (keyframes still refresh). + if (!visibleForSync && !lifeChanged && !visibilityChanged && !moveChanged) + needsStatePayload = false; + } + + var animPayload = needsAnim ? BuildAnimPayload(mob) : string.Empty; var mobType = BuildMobStateTypeSignature(mob); - var statePayload = BuildHostMobStatePayload(mob); + var statePayload = needsStatePayload + ? BuildHostMobStatePayload(mob) + : (hasPrevious ? previous.StatePayload : string.Empty); lock (Sync) { var flags = HostMobDirtyFlags.None; - if (!hostObservedMobStatesBySyncId.TryGetValue(syncId, out var previous)) + if (!hostObservedMobStatesBySyncId.TryGetValue(syncId, out previous)) { + if (!needsStatePayload) + statePayload = BuildHostMobStatePayload(mob); flags = HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState; } else @@ -134,7 +166,8 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) flags |= HostMobDirtyFlags.State; if (!string.Equals(previous.MobType, mobType, StringComparison.Ordinal) || - !string.Equals(previous.StatePayload, statePayload, StringComparison.Ordinal)) + (needsStatePayload && + !string.Equals(previous.StatePayload, statePayload, StringComparison.Ordinal))) flags |= HostMobDirtyFlags.State; if (visibleForSync) @@ -149,6 +182,10 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) if (moveChanged) flags |= previous.VisibleForSync ? HostMobDirtyFlags.Move : HostMobDirtyFlags.ForceState; } + else if (previous.VisibleForSync) + { + flags |= HostMobDirtyFlags.State; + } } hostObservedMobStatesBySyncId[syncId] = new HostMobObservedState( @@ -171,7 +208,7 @@ private static void ObserveClientMobForDirtyQueue(Mob mob) { if (mob == null || !IsSyncMob(mob)) return; - if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) + if (!TryGetMobSyncId(mob, out var syncId) || syncId <= 0) return; bool isOutOfGame; @@ -207,7 +244,7 @@ private static void QueueInitialMobSync(Mob mob) if (mob == null || !IsSyncMob(mob)) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsHost(net)) { QueueHostMobDirty(mob, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); @@ -234,7 +271,7 @@ private static void QueueHostMobDirty(Mob mob, HostMobDirtyFlags flags) { if (mob == null || flags == HostMobDirtyFlags.None) return; - if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) + if (!TryGetMobSyncId(mob, out var syncId) || syncId <= 0) return; lock (Sync) @@ -247,7 +284,7 @@ private static void QueueClientMobDirty(Mob mob, ClientMobDirtyFlags flags) { if (mob == null || flags == ClientMobDirtyFlags.None) return; - if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) + if (!TryGetMobSyncId(mob, out var syncId) || syncId <= 0) return; lock (Sync) @@ -889,7 +926,7 @@ private static void TryMarkMobAffectDirty(Entity? entity) if (entity is not Mob mob || !IsSyncMob(mob)) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsHost(net)) { QueueHostMobDirty(mob, HostMobDirtyFlags.State); diff --git a/Mobs/MonsterSynchronization.DownedRecovery.cs b/Mobs/MonsterSynchronization.DownedRecovery.cs index 42a2dfd..942741b 100644 --- a/Mobs/MonsterSynchronization.DownedRecovery.cs +++ b/Mobs/MonsterSynchronization.DownedRecovery.cs @@ -47,7 +47,7 @@ private static void ResetPlayerCombatStateRepairLocked() private static void RunHostPlayerCombatStateRepairIfPending() { - if (!IsHost(GameMenu.NetRef)) + if (!IsHost(LobbySession.NetRef)) return; var revision = Volatile.Read(ref s_playerCombatStateRevision); @@ -136,7 +136,7 @@ private static bool TryRepairHostMobAfterPlayerCombatStateChange(Mob mob) /// private static void TryMaintainHostBossSurvivorTarget(Mob mob) { - if (mob == null || !IsHost(GameMenu.NetRef) || !BossSyncHelpers.IsBossMob(mob) || + if (mob == null || !IsHost(LobbySession.NetRef) || !BossSyncHelpers.IsBossMob(mob) || !ModEntry.HasAnyPlayerDownedForCombat() || !IsMobHostileToPlayers(mob)) { return; diff --git a/Mobs/MonsterSynchronization.HostSend.cs b/Mobs/MonsterSynchronization.HostSend.cs index 52604f3..5c04de6 100644 --- a/Mobs/MonsterSynchronization.HostSend.cs +++ b/Mobs/MonsterSynchronization.HostSend.cs @@ -37,7 +37,7 @@ private static bool IsMobOnScreenForSync(Mob mob) if (hasVisibility && isOnScreen) return true; - if (IsHost(GameMenu.NetRef) && TryGetMobSyncId(mob, out var mobSyncId) && mobSyncId >= 0 && + if (IsHost(LobbySession.NetRef) && TryGetMobSyncId(mob, out var mobSyncId) && mobSyncId >= 0 && IsMobClientVisibleForSync(mobSyncId)) return true; @@ -101,7 +101,7 @@ private static void TryRecoverClientSyncMobLifeAfterLocalDamage(Mob? mob, int fa if (mob == null || mob.destroyed) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsClient(net) || !IsSyncMob(mob)) return; @@ -459,7 +459,7 @@ private static string ExtractAffectPresenceSignature(string? payload) private void Hook_Mob_contactAttack(Hook_Mob.orig_contactAttack orig, Mob self, Entity pow) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsHost(net) && IsInvalidPlayerTargetEntity(pow)) return; @@ -474,7 +474,7 @@ private void Hook_Mob_contactAttack(Hook_Mob.orig_contactAttack orig, Mob self, private void Hook_Mob_onTouch(Hook_Mob.orig_onTouch orig, Mob self, Entity atk) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsHost(net) && IsInvalidPlayerTargetEntity(atk)) return; @@ -490,7 +490,7 @@ private void Hook_Mob_onTouch(Hook_Mob.orig_onTouch orig, Mob self, Entity atk) private void Hook_OldMobSkill_execute(Hook_OldMobSkill.orig_execute orig, OldMobSkill self, double? a) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var ownerMob = self?.owner as Mob; if (ShouldBlockAutonomousClientBossSkill(net, ownerMob)) return; @@ -516,7 +516,7 @@ private void Hook_OldMobSkill_execute(Hook_OldMobSkill.orig_execute orig, OldMob private bool Hook_OldSkill_prepare(Hook_OldSkill.orig_prepare orig, OldSkill self, int? data) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var ownerMob = self?.owner as Mob; if (ShouldBlockAutonomousClientBossSkill(net, ownerMob)) return false; @@ -549,7 +549,7 @@ private bool Hook_OldSkill_prepare(Hook_OldSkill.orig_prepare orig, OldSkill sel private void Hook_OldSkill_execute(Hook_OldSkill.orig_execute orig, OldSkill self, double? ratio) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var ownerMob = self?.owner as Mob; if (ShouldBlockAutonomousClientBossSkill(net, ownerMob)) return; @@ -578,7 +578,7 @@ private void Hook_OldSkill_execute(Hook_OldSkill.orig_execute orig, OldSkill sel private bool Hook_OldMobSkill_prepareOnOwnerTarget(Hook_OldMobSkill.orig_prepareOnOwnerTarget orig, OldMobSkill self, bool? data, int? e) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var ownerMob = self?.owner as Mob; if (ShouldBlockAutonomousClientBossSkill(net, ownerMob)) return false; @@ -611,7 +611,7 @@ private bool Hook_OldMobSkill_prepareOnOwnerTarget(Hook_OldMobSkill.orig_prepare private void Hook_Mob_queueAttack(Hook_Mob.orig_queueAttack orig, Mob self, OldMobSkill a, bool requiresTargetInArea, int? data) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsClient(net) && IsSyncMob(self) && !IsClientNetworkQueuedAttackAllowed(self)) return; @@ -707,7 +707,7 @@ private static bool ShouldBlockAutonomousClientBossSkill(NetNode? net, Mob? owne private void Hook_MobSkill_execute(Hook_MobSkill.orig_execute orig, MobSkill self, double? ratio) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var ownerMob = self?.owner as Mob; if (ShouldBlockAutonomousClientBossSkill(net, ownerMob)) return; diff --git a/Mobs/MonsterSynchronization.Registry.cs b/Mobs/MonsterSynchronization.Registry.cs index 5595a3a..5e54f48 100644 --- a/Mobs/MonsterSynchronization.Registry.cs +++ b/Mobs/MonsterSynchronization.Registry.cs @@ -22,7 +22,7 @@ public partial class MobsSynchronization private static int s_hostMobRegistryResendsRemaining; private static bool IsHostAuthorityForNetIds() => - GameMenu.NetRef?.IsHost == true; + LobbySession.NetRef?.IsHost == true; /// /// Host assigns NetIds in walk order (assignment order only). Clients track unbound mobs @@ -45,6 +45,8 @@ private static void AssignHostNetIdsForRebuildLocked(IReadOnlyList candidat if (!IsHostAuthorityForNetIds()) continue; + if (nextRuntimeSyncId < 1) + nextRuntimeSyncId = 1; var netId = nextRuntimeSyncId++; MobToId[mob] = netId; IdToMob[netId] = mob; @@ -179,6 +181,8 @@ private static void ConsumeIncomingMobRegistry(NetNode net) for (var i = 0; i < entries.Count; i++) { var entry = entries[i]; + if (entry.NetId <= 0) + continue; if (!ShouldAcceptPacketGenerationLocked(entry.Generation, ref rejectedCount, ref rejectedGeneration)) continue; @@ -195,7 +199,8 @@ private static void ConsumeIncomingMobRegistry(NetNode net) entry.X, entry.Y, reservedMobs: null, - out var bound) && + out var bound, + out var duplicateTwin) && bound != null) { MobSyncTrace.LogBindSyncId( @@ -207,6 +212,20 @@ private static void ConsumeIncomingMobRegistry(NetNode net) continue; } + if (duplicateTwin) + { + // A same-cell same-type mob already holds a different NetId for this + // enemy (host wrapper/proxy duplication). Never build a duplicate + // replica — the enemy is already present locally under the other id. + MobSyncTrace.LogIncomingMappingMismatch( + "registry_duplicate_twin", + entry.NetId, + entry.Type ?? string.Empty, + string.Empty, + "same_enemy_already_bound"); + continue; + } + // No local mob to bind. For level-bootstrap mobs this means the packet was // early and a later resync repairs it, but for a host runtime spawn (malaise // wave, summon, elite replacement) the mob simply does not exist here and @@ -249,18 +268,45 @@ private static bool TryBindUnboundMobByTypeAndSpawnLocked( double y, HashSet? reservedMobs, out Mob? bound) + { + return TryBindUnboundMobByTypeAndSpawnLocked(netId, type, x, y, reservedMobs, out bound, out _); + } + + /// + /// One-shot bind for an unbound local mob: matching type + nearest spawn within distance. + /// Never steals a healthy NetId mapping from another living mob. + /// + /// + /// True when no unbound candidate exists but a same-cell same-type mob that is ALREADY + /// bound to a different NetId was found. That means the host handed one native enemy two + /// NetIds (wrapper/proxy duplication); the enemy already exists locally, so the caller must + /// NOT build a replica for this entry. + /// + private static bool TryBindUnboundMobByTypeAndSpawnLocked( + int netId, + string? type, + double x, + double y, + HashSet? reservedMobs, + out Mob? bound, + out bool duplicateTwin) { bound = null; - if (netId < 0 || string.IsNullOrWhiteSpace(type)) + duplicateTwin = false; + if (netId <= 0 || string.IsNullOrWhiteSpace(type)) return false; if (!double.IsFinite(x) || !double.IsFinite(y)) return false; var maxDistanceSq = ClientRegistryBindMaxDistancePx * ClientRegistryBindMaxDistancePx; + QuantizeWorldPositionToCells(x, y, out var entryCx, out var entryCy); var bestDistanceSq = double.MaxValue; var secondBestDistanceSq = double.MaxValue; Mob? best = null; + var bestCellExact = false; + var secondBestCellExact = false; var candidateCount = 0; + var twinCount = 0; for (var i = 0; i < trackedMobs.Count; i++) { @@ -269,8 +315,6 @@ private static bool TryBindUnboundMobByTypeAndSpawnLocked( continue; if (!IsStateRebindCandidateLocked(mob)) continue; - if (MobToId.TryGetValue(mob, out _)) - continue; if (!DoesMobMatchStateType(mob, type)) continue; @@ -293,23 +337,51 @@ private static bool TryBindUnboundMobByTypeAndSpawnLocked( if (distanceSq > maxDistanceSq) continue; + GetMobWorldCells(mob, out var mobCx, out var mobCy); + var cellExact = mobCx == entryCx && mobCy == entryCy; + + var isBound = MobToId.TryGetValue(mob, out _); + if (isBound) + { + // Already owned by another NetId. A same-cell same-type "twin" means the host + // gave one native enemy two ids; the enemy is already present, so remember the + // twin for the caller instead of spawning a duplicate replica. + if (cellExact) + twinCount++; + continue; + } + candidateCount++; - if (distanceSq < bestDistanceSq) + var prefer = best == null || + (cellExact && !bestCellExact) || + (cellExact == bestCellExact && distanceSq < bestDistanceSq); + if (prefer) { - secondBestDistanceSq = bestDistanceSq; + if (best != null) + { + secondBestDistanceSq = bestDistanceSq; + secondBestCellExact = bestCellExact; + } bestDistanceSq = distanceSq; + bestCellExact = cellExact; best = mob; } - else if (distanceSq < secondBestDistanceSq) + else if (cellExact == bestCellExact && distanceSq < secondBestDistanceSq) { secondBestDistanceSq = distanceSq; + secondBestCellExact = cellExact; } } if (best == null) + { + duplicateTwin = twinCount > 0; return false; + } - if (candidateCount > 1 && secondBestDistanceSq < double.MaxValue) + if (candidateCount > 1 && + secondBestDistanceSq < double.MaxValue && + secondBestCellExact == bestCellExact) { var gap = Math.Sqrt(secondBestDistanceSq) - Math.Sqrt(bestDistanceSq); if (!IsBossRelatedEntity(type) && gap < ClientStateRebindMinimumGapPx) diff --git a/Mobs/MonsterSynchronization.StallRecovery.cs b/Mobs/MonsterSynchronization.StallRecovery.cs index 8f6aeb3..d7142cc 100644 --- a/Mobs/MonsterSynchronization.StallRecovery.cs +++ b/Mobs/MonsterSynchronization.StallRecovery.cs @@ -68,7 +68,7 @@ private static void ResetHostMobStallRecoveryMotion(Mob mob) /// private static void TryRecoverHostStalledMob(Mob mob) { - if (mob == null || !IsHost(GameMenu.NetRef) || !IsSyncMob(mob)) + if (mob == null || !IsHost(LobbySession.NetRef) || !IsSyncMob(mob)) return; if (BossSyncHelpers.IsBossMob(mob) || !IsMobHostileToPlayers(mob)) return; diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 458e543..d046e8e 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -48,7 +48,11 @@ private sealed class MobSyncAlias public int Generation; } private static ConditionalWeakTable s_mobSyncAliases = new(); - private static int nextRuntimeSyncId; + /// + /// Host NetId allocator. Starts at 1 so wire Index 0 stays a reserved "none/invalid" sentinel + /// and cannot be reintroduced by ghost-echo / hit-fallback rebinds after the owning mob dies. + /// + private static int nextRuntimeSyncId = 1; private static readonly Dictionary clientMobTargets = new(ReferenceEqualityComparer.Instance); private static readonly Dictionary clientCachedAttackTargetByMob = new(ReferenceEqualityComparer.Instance); @@ -478,7 +482,7 @@ public static void ClearTrackingForLevelChange() levelId = GetLevelTraceIdSafe(currentLevel); ResetMobTrackingLocked("level_change_external"); } - try { GameMenu.NetRef?.ClearMobSyncQueues(); } catch { } + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } MobSyncTrace.LogLevelReset("external", levelId, trackedBeforeReset); } @@ -541,7 +545,7 @@ void IOnFrameUpdate.OnFrameUpdate(double dt) // A malformed/stale packet or a weapon-specific Hashlink wrapper exception must not // escape the frame receiver and close the entire game. Preserve the mob registry, // discard only transient network work, and let the periodic host full-resync heal it. - try { GameMenu.NetRef?.ClearMobSyncQueues(); } catch { } + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } var now = System.Diagnostics.Stopwatch.GetTimestamp(); var minTicks = System.Diagnostics.Stopwatch.Frequency * 3L; @@ -565,7 +569,7 @@ private void OnFrameUpdateCore(double dt) return; } - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) return; @@ -807,7 +811,7 @@ private static bool TryIgnoreCommittedIdentityEntitiesPostCreate(Level? level) var levelId = GetLevelTraceIdSafe(level); var levelKey = GetLevelRuntimeKey(level); var entityCount = GetEntityCountSafe(level); - var role = MobSyncNetRoleForTrace(GameMenu.NetRef); + var role = MobSyncNetRoleForTrace(LobbySession.NetRef); var trackedCurrent = 0; var currentLevelKey = string.Empty; var shouldLog = false; @@ -943,7 +947,7 @@ private static void Hook_Level_entitiesPostCreate(Hook_Level.orig_entitiesPostCr var levelId = GetLevelTraceIdSafe(self); var levelKey = GetLevelRuntimeKey(self); var entityCount = GetEntityCountSafe(self); - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var role = MobSyncNetRoleForTrace(net); var trackedBefore = 0; var currentLevelKey = string.Empty; @@ -1038,13 +1042,13 @@ private static void Hook_Level_registerEntity(Hook_Level.orig_registerEntity ori if (registerDeferred) { MobSyncTrace.LogDeferredMobRegistration( - GameMenu.NetRef?.IsHost == true ? "host" : (GameMenu.NetRef?.IsAlive == true ? "client" : "none"), + LobbySession.NetRef?.IsHost == true ? "host" : (LobbySession.NetRef?.IsAlive == true ? "client" : "none"), GetLevelTraceIdSafe(self), BuildMobStateTypeSignature(mob)); return; } - var regNet = GameMenu.NetRef; + var regNet = LobbySession.NetRef; var regRole = regNet == null || !regNet.IsAlive ? "none" : (regNet.IsHost ? "host" : "client"); if (registerLocalIndex >= 0) MobSyncTrace.LogRegisterTracked(regRole, registerSyncId, registerLocalIndex, BuildMobStateTypeSignature(mob)); @@ -1083,8 +1087,11 @@ private static void Hook_Level_onDispose(Hook_Level.orig_onDispose orig, Level s levelId = GetLevelTraceIdSafe(self); ResetMobTrackingLocked("level_dispose_before_orig"); } - try { GameMenu.NetRef?.ClearMobSyncQueues(); } catch { } + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } MobSyncTrace.LogLevelReset("dispose", levelId, trackedBeforeReset); + // Homunculus.dispose writes hero.controller.manualLock with no null check. Heal and + // pre-dispose Homunculi before the native Level.onDispose → runEntitiesGC path. + try { ModEntry.PrepareLevelProcessTeardown(self, "level_dispose_before"); } catch { } orig(self); @@ -1096,7 +1103,7 @@ private static void Hook_Level_onDispose(Hook_Level.orig_onDispose orig, Level s private void Hook_Mob_preUpdate(Hook_Mob.orig_preUpdate orig, Mob self) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var isHost = IsHost(net); var isClient = IsClient(net); @@ -1135,7 +1142,7 @@ private void Hook_Mob_preUpdate(Hook_Mob.orig_preUpdate orig, Mob self) private void Hook_Mob_fixedupdate(Hook_Mob.orig_fixedUpdate orig, Mob self) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (IsClient(net) && IsSyncMob(self)) { orig(self); @@ -1148,7 +1155,7 @@ private void Hook_Mob_fixedupdate(Hook_Mob.orig_fixedUpdate orig, Mob self) private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; var isHost = IsHost(net); if (!isHost) @@ -1197,7 +1204,7 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) var isBossDeathCandidate = false; if (self != null && suppressMobDieSendDepth <= 0) { - dieNet = GameMenu.NetRef; + dieNet = LobbySession.NetRef; isClient = IsClient(dieNet); isBossDeathCandidate = BossSyncHelpers.IsBossMob(self); @@ -1323,7 +1330,7 @@ private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData // Cache ids before orig so hit|life still sends when the mob is already untracked/destroyed. var preSyncOk = false; var cachedMobSyncId = -1; - if (self != null && i != null && GameMenu.NetRef != null && IsSyncMob(self)) + if (self != null && i != null && LobbySession.NetRef != null && IsSyncMob(self)) { preSyncOk = TryGetMobSyncId(self, out cachedMobSyncId); } @@ -1339,7 +1346,7 @@ private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData if (self == null || i == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null) return; @@ -1376,6 +1383,11 @@ private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData mobSyncId = cachedMobSyncId; } + // NetId 0 is reserved. Never report damage against it — that is the courtyard + // syncId=0 thrash vector once the real owner was gone. + if (mobSyncId <= 0) + return; + // A locally lethal client hit temporarily restores the mob so the client does not // run an unsanctioned death. Still report life=0 to the host; reporting the restored // value (usually 1) was the source of elites becoming permanently unkillable. @@ -1522,7 +1534,7 @@ private static bool ShouldSuppressClientBossDie(Mob? mob) if (mob == null || !BossSyncHelpers.IsBossMob(mob)) return false; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsClient(net)) return false; if (!IsSyncMob(mob)) @@ -1558,7 +1570,7 @@ private static void TryRecoverSuppressedClientBossDie(Mob? mob, int fallbackLife if (mob == null || mob.destroyed) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsClient(net)) { ClearSuppressedClientBossDie(mob); @@ -1589,7 +1601,7 @@ private static void RunWithAuthoritativeClientMobDie(Mob? mob, Action action) if (action == null) return; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (!IsClient(net) || mob == null || !IsSyncMob(mob)) { action(); diff --git a/ModEntry/ModEntry.CameraSpectate.cs b/ModEntry/ModEntry.CameraSpectate.cs index 5a7f481..52c88b0 100644 --- a/ModEntry/ModEntry.CameraSpectate.cs +++ b/ModEntry/ModEntry.CameraSpectate.cs @@ -100,6 +100,8 @@ private void OnCameraSpectateWindowEvent(Event e) private static bool IsCameraCycleTextInputBlocked() { + if (DeadCellsMultiplayerMod.MultiplayerModUI.Connection.ConnectionUI.IsTextPromptOpen()) + return true; var activeTextInput = TextInputHandler.GetActiveTextInput(); return activeTextInput != null && TextInputHandler.IsTextInputActive(activeTextInput); } diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index a040f40..e1d1e81 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -1,9 +1,12 @@ using System.Diagnostics; using System.Reflection; +using dc.en; using dc.pr; using ModCore.Utilities; using dc.tool; using HaxeProxy.Runtime; +using HaxeProxy.Runtime.Internals; +using HaxeProxy.Runtime.Internals.Cache; using DeadCellsMultiplayerMod.Ghost.GhostBase; using DeadCellsMultiplayerMod.KingHead; using DeadCellsMultiplayerMod.Tools; @@ -836,7 +839,7 @@ private void CancelPendingClientDispose(int slot) try { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net != null && net.IsAlive && net.IsHost && clientIds[slot] > 0) { global::DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization @@ -904,6 +907,343 @@ private static bool TryDisposeRuntimeProcessImmediately(object? process) } } + private static ObjFieldInfoCache _cachedProcessControllerField; + private const int HomunculusEntityClassId = 17969; + + /// + /// Assigns a live ControllerAccess to a mod-created Process (GameCinematic / ui.Process). + /// Some native onDispose paths (and cines that assume Game.controller) read controller + /// state during teardown. Vanilla process types install a controller during init(); + /// mod-created cines and UI processes may not, so giving the proxy a controller before + /// dispose keeps those paths on the normal vanilla route. + /// + public static bool TryAssignProcessController(object? process, dc.pr.Game? game) + { + if (process == null || game == null) + return false; + + try + { + var controller = game.controller; + if (controller == null) + return false; + + HaxeProxyHelper.SetFieldById( + (HaxeProxyBase)(object)process, + controller, + "controller", + ref _cachedProcessControllerField); + return true; + } + catch + { + return false; + } + } + + private static ObjFieldInfoCache _cachedProcessDestroyedField; + + public static bool TryReadProcessDestroyed(object? process) + { + if (process == null) + return false; + + try + { + var raw = HaxeProxyHelper.GetFieldById( + (HaxeProxyBase)(object)process, + "destroyed", + ref _cachedProcessDestroyedField); + if (raw is bool b) + return b; + if (raw is int i) + return i != 0; + return false; + } + catch + { + return false; + } + } + + private static ObjFieldInfoCache _cachedProcessControllerReadField; + + public static bool TryReadProcessControllerNull(object? process) + { + if (process == null) + return true; + + try + { + var raw = HaxeProxyHelper.GetFieldById( + (HaxeProxyBase)(object)process, + "controller", + ref _cachedProcessControllerReadField); + return raw == null; + } + catch + { + return true; + } + } + + private static ObjFieldInfoCache _cachedProcessChildrenField; + + /// + /// Homunculus.dispose always ends with `game.hero.controller.manualLock = false` with no + /// null check. During Level.onDispose → runEntitiesGC that becomes + /// `Null access .manualLock` whenever the live hero has no ControllerAccess (mid-run + /// restart, half-inited puppet, or hero disposed earlier in the same GC pass). Dispose + /// Homunculi first while we can still heal hero.controller, then strip them from the + /// level collections so the native GC pass cannot hit the bad path. + /// + internal static void PrepareLevelProcessTeardown(Level? level, string context) + { + var game = level?.game ?? dc.pr.Game.Class.ME; + try { LogProcessTeardownDiagnostics(context, game, level); } catch { } + + try { EnsureHeroControllerForTeardown(game); } catch { } + try { SafeDisposeHomunculiForTeardown(level, game, context); } catch { } + } + + private static void EnsureHeroControllerForTeardown(dc.pr.Game? game) + { + if (game == null) + return; + + dc.en.Hero? hero = null; + try { hero = game.hero; } catch { } + if (hero == null) + return; + + try + { + if (hero.controller != null) + return; + } + catch + { + return; + } + + try + { + var bootController = dc.Boot.Class.ME?.controller; + if (bootController == null) + return; + + hero.controller = bootController.createAccess("hero".AsHaxeString(), null); + ModEntry.Instance?.Logger?.Warning( + "[ProcessTeardown] healed null hero.controller before level teardown"); + } + catch (Exception ex) + { + ModEntry.Instance?.Logger?.Warning( + "[ProcessTeardown] failed to heal hero.controller: {Message}", + ex.Message); + } + } + + private static void SafeDisposeHomunculiForTeardown( + Level? level, + dc.pr.Game? game, + string context) + { + if (level == null) + return; + + var found = new HashSet(); + CollectHomunculi(level.entities, found); + CollectHomunculi(level.qTreeEntities, found); + CollectHomunculi(level.entitiesGC, found); + CollectHomunculi(level.savedEntities, found); + + try + { + if (level.entitiesByClass?.get(HomunculusEntityClassId) is dc.hl.types.ArrayObj bucket) + CollectHomunculi(bucket, found); + } + catch + { + } + + if (found.Count == 0) + return; + + EnsureHeroControllerForTeardown(game); + + var disposed = 0; + foreach (var hom in found) + { + if (hom == null) + continue; + + try + { + try { RemoveHomunculusFromLevelCollections(level, hom); } catch { } + + try + { + if (!hom.destroyed) + hom.destroy(); + } + catch + { + } + + // Prefer native dispose only when hero.controller is safe; otherwise strip + // the entity without invoking the unconditional manualLock write. + var heroControllerSafe = false; + try { heroControllerSafe = game?.hero?.controller != null; } catch { } + + if (heroControllerSafe) + { + try { hom.dispose(); } catch { } + } + + disposed++; + } + catch + { + } + } + + ModEntry.Instance?.Logger?.Warning( + "[ProcessTeardown][{Context}] pre-disposed Homunculus count={Count}", + context, + disposed); + } + + private static void CollectHomunculi(dc.hl.types.ArrayObj? entries, HashSet into) + { + if (entries == null) + return; + + try + { + for (var i = 0; i < entries.length; i++) + { + if (entries.getDyn(i) is dc.en.Homunculus hom) + into.Add(hom); + } + } + catch + { + } + } + + private static void RemoveHomunculusFromLevelCollections(Level level, dc.en.Homunculus hom) + { + try { level.entities?.remove(hom); } catch { } + try { level.qTreeEntities?.remove(hom); } catch { } + try { level.savedEntities?.remove(hom); } catch { } + try { level.entitiesGC?.remove(hom); } catch { } + + try + { + if (level.entitiesByClass?.get(HomunculusEntityClassId) is dc.hl.types.ArrayObj bucket) + bucket.remove(hom); + } + catch + { + } + } + + internal static void LogProcessTeardownDiagnostics( + string context, + dc.pr.Game? game, + Level? level = null) + { + try + { + var log = ModEntry.Instance?.Logger; + if (log == null) + return; + + var cine = game?.curCine; + var hero = game?.hero; + var heroControllerNull = true; + try { heroControllerNull = hero?.controller == null; } catch { } + + var homunculusCount = 0; + try + { + if (level?.entitiesByClass?.get(HomunculusEntityClassId) is dc.hl.types.ArrayObj bucket) + homunculusCount = bucket.length; + } + catch + { + } + + log.Warning( + "[ProcessTeardown][{Context}] game={Game} gameControllerNull={GameCtrlNull} hero={Hero} heroControllerNull={HeroCtrlNull} homunculus={HomCount} curCine={Cine} curCineDestroyed={Destroyed} curCineControllerNull={ControllerNull}", + context, + game?.GetType().Name, + game?.controller == null, + hero?.GetType().Name, + heroControllerNull, + homunculusCount, + cine?.GetType().Name, + TryReadProcessDestroyed(cine), + TryReadProcessControllerNull(cine)); + + var roots = dc.libs.Process.Class.ROOTS; + if (roots == null) + return; + + var list = roots.array; + var destroyedWithNullController = 0; + if (list != null) + { + for (int i = 0; i < list.Count; i++) + WalkProcessDiagnostics(list[i], 0, ref destroyedWithNullController); + } + + log.Warning( + "[ProcessTeardown][{Context}] process-tree scan done; destroyedWithNullController={Count}", + context, + destroyedWithNullController); + } + catch + { + } + } + + private static void WalkProcessDiagnostics(object? process, int depth, ref int destroyedCount) + { + if (process == null || depth > 12) + return; + + try + { + if (TryReadProcessDestroyed(process) && TryReadProcessControllerNull(process)) + { + destroyedCount++; + ModEntry.Instance?.Logger?.Warning( + "[ProcessTeardown] destroyed+null-controller process depth={Depth} type={Type}", + depth, + process.GetType().FullName); + } + + var childrenObj = HaxeProxyHelper.GetFieldById( + (HaxeProxyBase)(object)process, + "children", + ref _cachedProcessChildrenField); + if (childrenObj is not dc.hl.types.ArrayObj children) + return; + + var list = children.array; + if (list == null) + return; + + for (int i = 0; i < list.Count; i++) + WalkProcessDiagnostics(list[i], depth + 1, ref destroyedCount); + } + catch + { + } + } + private void DisposeClientSlotForSubLevelTransition(int slot, bool clearIdentity) { if (slot < 0 || slot >= clients.Length) @@ -1623,6 +1963,11 @@ private void DisposeCoopGhostRuntime() } } + ClearCoopGhostRuntimeRefs(); + } + + private void ClearCoopGhostRuntimeRefs() + { var ghost = _ghost; _ghost = null!; _ghostOwnerHero = null; @@ -1634,7 +1979,32 @@ private void DisposeCoopGhostRuntime() internal void DisposeCoopGhostRuntimeForWorldTeardown(dc.pr.Game? disposingGame = null) { _ = disposingGame; - DisposeCoopGhostRuntime(); + + try + { + ResetFakeDeathState(unlockLocalHero: false, sendNetworkUpState: false); + } + catch + { + } + + // A world teardown (restart / exit) must NOT use the triple-dispose path of + // DisposeClientSlot (destroy+dispose+disposeGfx): it can leave a destroyed remote + // GhostKing in the level's process tree, and the next frame's Process._dispose then + // crashes on a null controller.manualLock. Use the same disposeImmediately-based path + // the sub-level transition guard relies on. + for (int i = 0; i < clients.Length; i++) + { + try + { + DisposeClientSlotForSubLevelTransition(i, clearIdentity: true); + } + catch + { + } + } + + ClearCoopGhostRuntimeRefs(); } internal void HandleNetworkDisconnectGhostCleanup(NetRole role) @@ -1734,7 +2104,7 @@ private void ResetDoorMarkerState() private void ResetNetworkState() { GameDataSync.RestoreOrigHpMultipliers(); - GameMenu.ClearPendingNetworkMainThreadActions(); + MainThreadPump.ClearPendingNetworkMainThreadActions(); GameDataSync.ResetTransientNetworkState(); global::DeadCellsMultiplayerMod.AdvancedCoop.CoopAdvancedHardening.ResetSessionState(); try { global::DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.ClearTrackingForLevelChange(); } catch { } diff --git a/ModEntry/ModEntry.JoinSpawn.cs b/ModEntry/ModEntry.JoinSpawn.cs index e8eaeef..371dc83 100644 --- a/ModEntry/ModEntry.JoinSpawn.cs +++ b/ModEntry/ModEntry.JoinSpawn.cs @@ -41,7 +41,7 @@ public partial class ModEntry private void ArmJoinSpawnForCurrentLevel(string? levelId) { - if (!GameMenu.TryConsumeMidRunJoinSpawn()) + if (!RunLaunchFlow.TryConsumeMidRunJoinSpawn()) return; _joinSpawnArmed = true; diff --git a/ModEntry/ModEntry.KingSkinRenderSafety.cs b/ModEntry/ModEntry.KingSkinRenderSafety.cs index 620e1db..9a1478d 100644 --- a/ModEntry/ModEntry.KingSkinRenderSafety.cs +++ b/ModEntry/ModEntry.KingSkinRenderSafety.cs @@ -303,7 +303,7 @@ private void CompleteRemoteKingSubLevelTransitionGuard(string completionReason) DrainRemoteCombatQueuesAfterLevelChange(); MarkDiveNetGuardAfterSpawnOrRoomChange(); SendCurrentRoomTarget(force: true); - GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); + MainThreadPump.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); Logger.Information( "[NetMod][SubLevelGuard] completed reason={CompletionReason} armedBy={ArmedReason}", diff --git a/ModEntry/ModEntry.NetworkMenu.cs b/ModEntry/ModEntry.NetworkMenu.cs index 86fc8fb..d9e5673 100644 --- a/ModEntry/ModEntry.NetworkMenu.cs +++ b/ModEntry/ModEntry.NetworkMenu.cs @@ -63,16 +63,16 @@ private void StartHostCore(Action createHost) } _net?.Dispose(); - GameMenu.NetRef = null; + LobbySession.NetRef = null; ResetNetworkState(); createHost(); // Publish the newly-created node immediately. Its accept/connect loops start inside the // factory, so delaying NetRef until after other setup left a small window where valid // early callbacks were treated as belonging to no/currently-superseded session. - GameMenu.NetRef = _net; + LobbySession.NetRef = _net; _netRole = NetRole.Host; _net?.SendHpMultipliers(); - GameMenu.SetRole(_netRole); + LobbySession.SetRole(_netRole); ConnectionUI.NotifyConnectionsChanged(); } @@ -130,7 +130,7 @@ private void LogHostJoinAddresses(int port) private void StartClientCore(Action createClient) { _net?.Dispose(); - GameMenu.NetRef = null; + LobbySession.NetRef = null; var main = dc.Main.Class.ME; if (main?.user != null) GameDataSync.RestoreOriginalUserState(main.user, true); @@ -138,9 +138,9 @@ private void StartClientCore(Action createClient) createClient(); // Publish before UI setup for the same reason as the host path: the background // connection loop is already active when the factory returns. - GameMenu.NetRef = _net; + LobbySession.NetRef = _net; _netRole = NetRole.Client; - GameMenu.SetRole(_netRole); + LobbySession.SetRole(_netRole); ConnectionUI.NotifyConnectionsChanged(); } @@ -208,11 +208,11 @@ public void StopNetworkFromMenu() // Invalidate the public session reference before draining/resetting queues. Otherwise // a late callback from the disposed node can still pass IsCurrentNetworkSession and // repopulate the just-cleared state. - GameMenu.NetRef = null; + LobbySession.NetRef = null; ResetNetworkState(); _net = null; _netRole = NetRole.None; - GameMenu.SetRole(_netRole); + LobbySession.SetRole(_netRole); ConnectionUI.NotifyConnectionsChanged(); } } diff --git a/ModEntry/ModEntry.Steam.cs b/ModEntry/ModEntry.Steam.cs index da9f79c..f13ba31 100644 --- a/ModEntry/ModEntry.Steam.cs +++ b/ModEntry/ModEntry.Steam.cs @@ -21,7 +21,7 @@ private static void TryParseConnectLobbyFromCommandLine() ulong.TryParse(args[i + 1], out var lobbyId) && lobbyId > 0) { Instance?.Logger.Information("[NetMod][Steam] Launch parameter +connect_lobby detected lobbyId={LobbyId}", lobbyId); - GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId)); + MainThreadPump.EnqueueMainThreadCoalesced("steam:overlay-join", () => LobbySession.HandleSteamOverlayJoinRequest(lobbyId)); return; } } @@ -140,7 +140,7 @@ private static void EnqueueAndProcessOverlayJoin(ulong lobbyId, string source) s_lastOverlayJoinLobbyId = lobbyId; s_lastOverlayJoinTicks = nowTicks; Instance?.Logger.Information("[NetMod][Steam] Queueing overlay join request lobbyId={LobbyId} source={Source}", lobbyId, source); - GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId)); + MainThreadPump.EnqueueMainThreadCoalesced("steam:overlay-join", () => LobbySession.HandleSteamOverlayJoinRequest(lobbyId)); } private static ulong TryParseLobbyIdFromConnectString(string connect) @@ -187,7 +187,7 @@ internal static bool TryRunSteamCallbacksSerialized() } /// - /// Call from GameMenu when at main menu so Steam overlay join callbacks are pumped even if frame update is throttled. + /// Call from LobbySession when at main menu so Steam overlay join callbacks are pumped even if frame update is throttled. /// internal static void PumpSteamCallbacksForOverlay() { @@ -372,8 +372,8 @@ internal static void ShutdownMultiplayerForProcessExit(string reason) try { - var node = GameMenu.NetRef ?? _net; - GameMenu.NetRef = null; + var node = LobbySession.NetRef ?? _net; + LobbySession.NetRef = null; _net = null; node?.Dispose(); } @@ -382,7 +382,7 @@ internal static void ShutdownMultiplayerForProcessExit(string reason) // Never let shutdown cleanup throw out of a ProcessExit handler. } - try { GameMenu.ClearPendingNetworkMainThreadActions(); } catch { } + try { MainThreadPump.ClearPendingNetworkMainThreadActions(); } catch { } try { diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 1115b7e..c715415 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -430,7 +430,7 @@ private static string BuildRemoteLabel(int remoteId, string? username) public void OnGameEndInit() { _ready = true; - GameMenu.SetRole(NetRole.None); + LobbySession.SetRole(NetRole.None); s_steamOverlayCallbackPending = true; s_steamOverlayCallbackRetryCount = 0; _debugPerkAppliedHero = null; @@ -490,7 +490,7 @@ public override void Initialize() _ = new CoopAdvancedHardening(this); - GameMenu.Initialize(Logger); + LobbySession.Initialize(Logger); s_steamOverlayCallbackPending = true; s_steamOverlayCallbackRetryCount = 0; EventSystem.BroadcastEvent(this); @@ -675,7 +675,7 @@ private void Hook_ZDoor_onActivate(Hook_ZDoor.orig_onActivate orig, ZDoor self, if (localMultiplayerActivation) { SendCurrentRoomTarget(force: true); - GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); + MainThreadPump.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); } } @@ -701,7 +701,7 @@ private static bool IsBossRushDoorMissingFrameException(Exception ex) } /// - /// At most one deferred orig(self) per door instance. + /// At most one deferred orig(self) per door instance. /// private static readonly ConditionalWeakTable s_bossRushDoorGfxDeferredPending = new(); @@ -739,7 +739,7 @@ private void Hook_BossRushDoor_initGfx(Hook_BossRushDoor.orig_initGfx orig, Boss s_bossRushDoorGfxDeferredPending.Add(self, new object()); var localOrig = orig; var localSelf = self; - GameMenu.EnqueueMainThread(() => + MainThreadPump.EnqueueMainThread(() => { try { @@ -872,7 +872,7 @@ private void Hook__Save_save(Hook__Save.orig_save orig, User u, bool onlyGameDat // client does not own the authoritative run and transient remote identities can be // serialized into MSave. The host remains the only writer while the session is live; // normal local saving resumes after disconnect/role reset. - var menuRole = GameMenu.CurrentRole; + var menuRole = LobbySession.CurrentRole; if (_netRole == NetRole.Client || menuRole == NetRole.Client) { Logger.Debug( @@ -907,7 +907,7 @@ private void Hook__Save_save(Hook__Save.orig_save orig, User u, bool onlyGameDat // The serializer scope is entered for EVERY role, not only NetRole.Client. // // TryApplyRemoteSerializerSync installs the host's hxbit SEQ/UID globally on each client - // level generation, and that is global state which outlives role bookkeeping: GameMenu's + // level generation, and that is global state which outlives role bookkeeping: LobbySession's // _role and this class's _netRole are separate fields, SetRole only swaps back when it // observes a Client->other transition, and SwapToLocalSerializerSync silently returns // false if the serializer class is unavailable. Any of those divergences used to send the @@ -941,8 +941,8 @@ private void hook_boot_update(Hook_Boot.orig_update orig, Boot self, double dt) { orig(self, dt); PumpSteamCallbacksForOverlay(); - GameMenu.ProcessMainThreadQueue(); - GameMenu.HandleTextInputClipboardShortcuts(); + MainThreadPump.ProcessMainThreadQueue(); + LobbySession.HandleTextInputClipboardShortcuts(); _ghost?.UpdateLabels(); ProcessCameraSpectateInput(); TickRemoteKingSubLevelTransitionGuard(); @@ -976,7 +976,7 @@ private LevelStruct Hook__LevelStruct_get(Hook__LevelStruct.orig_get orig, "[NetMod][LevelSync] authoritative level seed for {LevelId} was not received within {Timeout}ms", levelId, levelSeedSyncWaitMs); - GameMenu.AbortClientWorldSync($"level seed timeout: {levelId}"); + LobbySession.AbortClientWorldSync($"level seed timeout: {levelId}"); } } @@ -1050,7 +1050,7 @@ private RoomNode Hook_LevelGen_generateGraph(Hook_LevelGen.orig_generateGraph or try { MultiplayerUI.PushSystemMessage( - GameMenu.Localize("Level failed to synchronize with the host. Return to the menu and rejoin."), + LobbySession.Localize("Level failed to synchronize with the host. Return to the menu and rejoin."), 8.0, 1.0); } @@ -1063,7 +1063,7 @@ private RoomNode Hook_LevelGen_generateGraph(Hook_LevelGen.orig_generateGraph or // that payload cached for the retry. If no payload exists at all, there is no // safe recovery path: leave rather than knowingly continue on a different map. if (!GameDataSync.HasPendingRemoteLevelGraph(graphLevelId)) - GameMenu.AbortClientWorldSync($"level graph unavailable: {graphLevelId} ({reason})"); + LobbySession.AbortClientWorldSync($"level graph unavailable: {graphLevelId} ({reason})"); } } @@ -1183,7 +1183,7 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le var net = _net; var localId = net?.id ?? 0; _ghost = new GhostHero(localId, game!, me, Logger, this); - _ghost.SetLabel(me, GameMenu.Username); + _ghost.SetLabel(me, LobbySession.Username); _ghostOwnerHero = me; _ghostOwnerGame = game; _ghostBootstrapNet = net; @@ -1197,7 +1197,7 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le ResetGhostHeadRuntimeState(i); } - GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); + MainThreadPump.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); } else { @@ -1247,7 +1247,7 @@ public void OnHeroInit() ApplyDebugHeroRuntimeOptions(); } - GameMenu.MarkInRun(); + LobbySession.MarkInRun(); ApplyDebugHeroRuntimeOptions(); } @@ -1256,9 +1256,10 @@ public void OnFrameUpdate(double dt) if (!_ready) return; var hitchStart = RuntimeHitchWatch.Start(); PumpSteamCallbacksForOverlay(); - GameMenu.ProcessMainThreadQueue(); + MainThreadPump.ProcessMainThreadQueue(); + PlayPopupWindowGuard.Tick(); CheckRemoteKingRenderSafety("frame"); - GameMenu.TickMenu(dt); + LobbySession.TickMenu(dt); DetectAndSendBossCine(); ApplyReceivedBossHeroTeleport(); ApplyReceivedBossCine(); @@ -1452,7 +1453,7 @@ private void EnsureCoopRuntimeBootstrap() if (_ghost == null) { _ghost = new GhostHero(net.id, localGame, me, Logger, this); - _ghost.SetLabel(me, GameMenu.Username); + _ghost.SetLabel(me, LobbySession.Username); _ghostOwnerHero = me; _ghostOwnerGame = localGame; _ghostBootstrapNet = null; diff --git a/Tools/PlayPopupWindowGuard.cs b/Tools/PlayPopupWindowGuard.cs new file mode 100644 index 0000000..4943379 --- /dev/null +++ b/Tools/PlayPopupWindowGuard.cs @@ -0,0 +1,232 @@ +using System; +using dc.h2d; +using dc.tool; +using dc.ui; +using Serilog; +using LibsProcess = dc.libs.Process; +using Sound = dc.hxd.res.Sound; + +namespace DeadCellsMultiplayerMod.Tools +{ + /// + /// Suppresses the title-screen cross-promo widget ("Play Windblown now!" / similar). + /// That UI is (and sometimes ), not a Win32 window. + /// + internal static class PlayPopupWindowGuard + { + private const string MatchPhrase = "Windblow"; + private const long FallbackPollIntervalMs = 500; + + private static bool _hooksInstalled; + private static bool _loggedNewsSuppress; + private static bool _loggedPopUpSuppress; + private static long _nextFallbackTicks = -1; + private static WeakReference? _newsRef; + + internal static void Tick() + { + EnsureHooks(); + + var now = Environment.TickCount64; + if (now < _nextFallbackTicks) + return; + _nextFallbackTicks = now + FallbackPollIntervalMs; + + try + { + if (_newsRef != null && _newsRef.TryGetTarget(out var news) && news != null) + SuppressNewsPanel(news, "fallback", scrubContent: false); + } + catch + { + } + } + + private static void EnsureHooks() + { + if (_hooksInstalled) + return; + + try + { + Hook__NewsPanel.__constructor__ += OnNewsPanelConstructed; + Hook_NewsPanel.onData += OnNewsPanelOnData; + Hook_NewsPanel.updateVisible += OnNewsPanelUpdateVisible; + Hook_NewsPanel.update += OnNewsPanelUpdate; + Hook_NewsPanel.openNews += OnNewsPanelOpenNews; + + Hook__UpdatePopUp.__constructor__ += OnUpdatePopUpConstructed; + Hook_UpdatePopUp.update += OnUpdatePopUpUpdate; + + _hooksInstalled = true; + Log.Debug("[NetMod] PlayPopupWindowGuard hooks installed (NewsPanel / UpdatePopUp)"); + } + catch (Exception ex) + { + Log.Warning(ex, "[NetMod] PlayPopupWindowGuard failed to install hooks"); + } + } + + private static void OnNewsPanelConstructed(Hook__NewsPanel.orig___constructor__ orig, NewsPanel self, LibsProcess parent) + { + orig(self, parent); + _newsRef = new WeakReference(self); + SuppressNewsPanel(self, "ctor", scrubContent: true); + } + + private static void OnNewsPanelOnData(Hook_NewsPanel.orig_onData orig, NewsPanel self, Result result) + { + orig(self, result); + _newsRef = new WeakReference(self); + SuppressNewsPanel(self, "onData", scrubContent: true); + } + + private static void OnNewsPanelUpdateVisible(Hook_NewsPanel.orig_updateVisible orig, NewsPanel self) + { + orig(self); + SuppressNewsPanel(self, "updateVisible", scrubContent: false); + } + + private static void OnNewsPanelUpdate(Hook_NewsPanel.orig_update orig, NewsPanel self) + { + orig(self); + SuppressNewsPanel(self, "update", scrubContent: false); + } + + private static void OnNewsPanelOpenNews(Hook_NewsPanel.orig_openNews orig, NewsPanel self) + { + // Block Steam / browser redirect for the promo tile. + SuppressNewsPanel(self, "openNews", scrubContent: true); + } + + private static void OnUpdatePopUpConstructed(Hook__UpdatePopUp.orig___constructor__ orig, UpdatePopUp self, Process from, Sound validSfx) + { + orig(self, from, validSfx); + TrySuppressUpdatePopUp(self, "ctor"); + } + + private static void OnUpdatePopUpUpdate(Hook_UpdatePopUp.orig_update orig, UpdatePopUp self) + { + orig(self); + TrySuppressUpdatePopUp(self, "update"); + } + + private static void SuppressNewsPanel(NewsPanel? self, string reason, bool scrubContent) + { + if (self == null) + return; + + try + { + if (!self.hidden) + self.hidden = true; + + if (scrubContent) + { + try + { + self.clean(); + } + catch + { + } + } + + HideRoot(self.root); + + if (!_loggedNewsSuppress) + { + _loggedNewsSuppress = true; + Log.Information("[NetMod] Suppressed title NewsPanel ({Reason})", reason); + } + } + catch (Exception ex) + { + Log.Debug(ex, "[NetMod] NewsPanel suppress failed ({Reason})", reason); + } + } + + private static void TrySuppressUpdatePopUp(UpdatePopUp? self, string reason) + { + if (self == null || self.closing) + return; + + if (!LooksLikeWindblownPromo(ReadLabel(self.title), ReadLabel(self.text))) + return; + + try + { + HideRoot(self.root); + self.close(); + + if (!_loggedPopUpSuppress) + { + _loggedPopUpSuppress = true; + Log.Information("[NetMod] Closed Windblown UpdatePopUp ({Reason})", reason); + } + } + catch (Exception ex) + { + Log.Debug(ex, "[NetMod] UpdatePopUp suppress failed ({Reason})", reason); + } + } + + private static bool LooksLikeWindblownPromo(string? title, string? body) + { + if (ContainsPhrase(title) || ContainsPhrase(body)) + return true; + + if (!string.IsNullOrEmpty(body) + && body.IndexOf("DASH, DIVE", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + return false; + } + + private static bool ContainsPhrase(string? value) + { + return !string.IsNullOrEmpty(value) + && value.IndexOf(MatchPhrase, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string? ReadLabel(dc.ui.Text? text) + { + if (text == null) + return null; + + try + { + var raw = text.rawText?.ToString(); + if (!string.IsNullOrEmpty(raw)) + return raw; + } + catch + { + } + + try + { + return text.text?.ToString(); + } + catch + { + return null; + } + } + + private static void HideRoot(Layers? root) + { + if (root == null) + return; + + try + { + if (root.visible) + root.visible = false; + } + catch + { + } + } + } +} diff --git a/Tools/UiChrome.cs b/Tools/UiChrome.cs new file mode 100644 index 0000000..cbd6ea0 --- /dev/null +++ b/Tools/UiChrome.cs @@ -0,0 +1,215 @@ +using dc.h2d; +using HaxeProxy.Runtime; + +namespace DeadCellsMultiplayerMod.Tools +{ + /// + /// Shared Graphics chrome for ConnectionUI: soft shadows, round-rect fills, raised plates, + /// inset wells, and hover rings. Dead Cells' has no drawRoundRect, + /// so corners are composited from rects + . + /// + internal static class UiChrome + { + /// + /// Fills a rounded rectangle without overlapping regions (safe for translucent fills). + /// + public static void FillRoundRect( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + int color, + double alpha) + { + if (g == null || w <= 0.5 || h <= 0.5) + return; + + double r = radius; + if (r < 0.0) + r = 0.0; + if (r * 2.0 > w) + r = w * 0.5; + if (r * 2.0 > h) + r = h * 0.5; + + int fillColor = color; + double fillAlpha = alpha; + g.beginFill(Ref.From(ref fillColor), Ref.From(ref fillAlpha)); + + if (r < 0.75) + { + g.drawRect(x, y, w, h); + g.endFill(); + return; + } + + // Center column (full height) + side strips (excluding corners) + four corner discs. + g.drawRect(x + r, y, w - 2.0 * r, h); + g.drawRect(x, y + r, r, h - 2.0 * r); + g.drawRect(x + w - r, y + r, r, h - 2.0 * r); + + var segments = Ref.Null; + g.drawCircle(x + r, y + r, r, segments); + g.drawCircle(x + w - r, y + r, r, segments); + g.drawCircle(x + r, y + h - r, r, segments); + g.drawCircle(x + w - r, y + h - r, r, segments); + g.endFill(); + } + + public static void DrawSoftShadow( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + double offsetX = 3.0, + double offsetY = 5.0) + { + if (g == null) + return; + + FillRoundRect(g, x + offsetX, y + offsetY, w, h, radius + 1.0, 0x000000, 0.22); + FillRoundRect(g, x + offsetX * 0.55, y + offsetY * 0.55, w, h, radius, 0x000000, 0.14); + FillRoundRect(g, x + 1.0, y + 2.0, w, h, radius, 0x000000, 0.08); + } + + /// Raised menu button: shadow + edge + face + top highlight. + public static void DrawRaisedPlate( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + int edgeColor, + int faceColor, + int highlightColor, + bool enabled) + { + if (g == null) + return; + + if (enabled) + DrawSoftShadow(g, x, y, w, h, radius); + else + DrawSoftShadow(g, x, y, w, h, radius, offsetX: 2.0, offsetY: 3.0); + + FillRoundRect(g, x, y, w, h, radius, edgeColor, enabled ? 1.0 : 0.88); + + double inset = 2.0; + double innerR = System.Math.Max(0.0, radius - inset); + FillRoundRect(g, x + inset, y + inset, w - inset * 2.0, h - inset * 2.0, innerR, faceColor, enabled ? 1.0 : 0.92); + + // Soft top sheen — quieter when disabled, but still navy (not flat gray). + double hx = x + radius; + double hw = w - radius * 2.0; + if (hw > 8.0) + { + int hi = highlightColor; + double a = enabled ? 1.0 : 0.35; + g.beginFill(Ref.From(ref hi), Ref.From(ref a)); + g.drawRect(hx, y + inset + 1.0, hw, 2.0); + g.endFill(); + } + } + + /// Recessed text field well: light shadow + border + dug-in face. + public static void DrawInsetWell( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + int borderColor, + int faceColor, + bool enabled) + { + if (g == null) + return; + + if (enabled) + DrawSoftShadow(g, x, y, w, h, radius, offsetX: 2.0, offsetY: 3.0); + + FillRoundRect(g, x, y, w, h, radius, borderColor, 1.0); + + double inset = 1.5; + double innerR = System.Math.Max(0.0, radius - inset); + FillRoundRect(g, x + inset, y + inset, w - inset * 2.0, h - inset * 2.0, innerR, faceColor, 1.0); + + // Top shade so the well reads as inset, not a raised plate. + int shade = 0x000000; + double shadeA = enabled ? 0.42 : 0.2; + double sx = x + radius; + double sw = w - radius * 2.0; + if (sw > 4.0) + { + g.beginFill(Ref.From(ref shade), Ref.From(ref shadeA)); + g.drawRect(sx, y + inset, sw, 2.5); + g.endFill(); + } + } + + /// + /// Rounded cyan hover ring. Drawn on a layer beneath labels, so the opaque + /// face punch restores the plate without covering text. + /// + public static void DrawHoverRing( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + int ringColor, + int faceColor) + { + if (g == null || w <= 1.0 || h <= 1.0) + return; + + const double glow = 4.0; + const double thick = 2.5; + + // Soft outer halo (rounded). + FillRoundRect(g, x - glow, y - glow, w + glow * 2.0, h + glow * 2.0, radius + glow, ringColor, 0.20); + // Solid rounded ring shell. + FillRoundRect(g, x - thick, y - thick, w + thick * 2.0, h + thick * 2.0, radius + thick, ringColor, 0.95); + // Punch the interior back to the plate/field face (hover layer sits under labels). + FillRoundRect(g, x, y, w, h, radius, faceColor, 1.0); + } + + /// Content card behind a button stack — softens empty full-screen navy. + public static void DrawContentCard( + Graphics g, + double x, + double y, + double w, + double h, + double radius, + int fillColor, + int edgeColor) + { + if (g == null || w <= 1.0 || h <= 1.0) + return; + + DrawSoftShadow(g, x, y, w, h, radius, offsetX: 4.0, offsetY: 7.0); + FillRoundRect(g, x, y, w, h, radius, edgeColor, 0.55); + FillRoundRect(g, x + 1.5, y + 1.5, w - 3.0, h - 3.0, System.Math.Max(0.0, radius - 1.5), fillColor, 0.92); + + // Quiet top lip. + int lip = 0x3A4A6E; + double lipA = 0.55; + double lx = x + radius; + double lw = w - radius * 2.0; + if (lw > 8.0) + { + g.beginFill(Ref.From(ref lip), Ref.From(ref lipA)); + g.drawRect(lx, y + 2.0, lw, 2.0); + g.endFill(); + } + } + } +} diff --git a/Tools/UiScale.cs b/Tools/UiScale.cs index 77f2d4d..3e65e55 100644 --- a/Tools/UiScale.cs +++ b/Tools/UiScale.cs @@ -6,13 +6,18 @@ namespace DeadCellsMultiplayerMod.Tools public static class UiScale { private const double ReferenceWidth = 1920.0; - private const double ReferenceHeight = 1080.0; private const double MinScale = 0.9; private const double MaxScale = 1.15; /// After device connect/disconnect the window can briefly report 0×0; avoid blurry/wrong UI scaling. private static double s_lastGoodScale = 1.0; + /// + /// Width-based scale for ConnectionUI. Dead Cells windowed/fullscreen toggles often change + /// usable height (title bar / taskbar) without changing width; Min(scaleW, scaleH) made the + /// hub typography and spacing jump with that height. Width matches the game's horizontal + /// stage sizing more stably across display modes. + /// public static double GetResolutionScale() { var win = Window.Class.getInstance(); @@ -20,16 +25,10 @@ public static double GetResolutionScale() return s_lastGoodScale; double width = win.get_width(); - double height = win.get_height(); - if (width <= 0 || height <= 0) + if (width <= 0) return s_lastGoodScale; - double scaleW = width / ReferenceWidth; - double scaleH = height / ReferenceHeight; - if (scaleW <= 0 || scaleH <= 0) - return 1.0; - - var scale = System.Math.Min(scaleW, scaleH); + var scale = width / ReferenceWidth; if (scale <= 0) return 1.0; diff --git a/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs b/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs new file mode 100644 index 0000000..d801f54 --- /dev/null +++ b/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs @@ -0,0 +1,369 @@ +using System; +using dc; +using dc.h2d; +using dc.libs.heaps.slib; +using dc.shader; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Modules; +using ModCore.Utilities; +using Serilog; +using DeadCellsMultiplayerMod.MultiplayerModUI.Connection.LightingInitializer; +using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; +using DeadCellsMultiplayerMod.Tools; + +namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection +{ + /// + /// Lobby beheaded row: four fixed seats with UIChrome plates, hero sprites, and nicks. + /// Uses the title-screen shader stack (ColorMap + DirLighted + NormalMap) — ColorMap alone is not cached. + /// + public partial class ConnectionUI + { + private const string DefaultLobbySkin = "PrisonerDefault"; + + /// Four lobby beheaded seats under / beside the lobby code card. + private dc.h2d.Object? _lobbyBeheadedRoot; + + private readonly List animlist = new() { "idle", "idle", "idle", "idle" }; + + private void ClearLobbyBeheadedSprites() + { + for (int i = 0; i < this.sprites.Count; i++) + { + try { this.sprites[i]?.remove(); } catch { } + } + this.sprites.Clear(); + + try { this.spritesflow?.remove(); } catch { } + this.spritesflow = null; + this.spriteui = null; + + try { this._lobbyBeheadedRoot?.remove(); } catch { } + this._lobbyBeheadedRoot = null; + } + + private void HideLobbyBeheadedSprites() + { + if (this._lobbyBeheadedRoot == null) + return; + try { this._lobbyBeheadedRoot.set_visible(false); } catch { } + } + + /// + /// Spawns the four lobby beheaded slots (UIChrome bg + sprite + nick). This IS the players list. + /// Position offsets are user-tuned; do not casually move the root. + /// + private void PlaceLobbyBeheadedUnderPlayerList( + List<_ConnectionUI.LobbyPlayerSlot> slots, + double panelW, + double uiScale, + double textUi, + double screenPad) + { + ClearLobbyBeheadedSprites(); + if (this._panelRoot == null) + return; + + // Scene lighting globals must be present for DirLighted (title-screen hero path). + EnsureLobbyBeheadedLighting(); + + // Beheaded positions are the anchor. Boxes/nicks are offset to frame them + // (do not move the sprites to chase the plates — move the plates). + const double beheadedScale = 2.75; + const double gapBetweenBeheaded = 24.0; + const double approxTileWidth = 48.0; + const double approxTileHeight = 56.0; + const double nickGap = 6.0; + const double boxPadX = 20.0; + const double boxPadY = 18.0; + // Whole row on screen (negative = left). + const double rootXNudge = -5.0; + // Plate vs beheaded — tune so the art sits in the middle of the box. + // Negative X = box left; negative Y = box up. + const double boxOffsetX = 5.0; + const double boxOffsetY = -75.0; + + double scale = beheadedScale * uiScale; + double bodyW = approxTileWidth * scale; + double bodyH = approxTileHeight * scale; + double padX = boxPadX * uiScale; + double padY = boxPadY * uiScale; + double slotW = bodyW + padX * 2.0; + double slotH = bodyH + padY * 2.0; + double step = slotW + gapBetweenBeheaded * uiScale; + double plateDX = boxOffsetX * uiScale; + double plateDY = boxOffsetY * uiScale; + + this._lobbyBeheadedRoot = new dc.h2d.Object(null); + this._panelRoot.addChild(this._lobbyBeheadedRoot); + + int slotCount = System.Math.Max(slots.Count, _ConnectionUI.LobbySlotCount); + double rowW = step * slotCount - gapBetweenBeheaded * uiScale; + this._lobbyBeheadedRoot.x = this._layoutW - System.Math.Max(panelW, rowW) - screenPad + rootXNudge; + + double belowCode = this._lobbyPanelHeight > 0 + ? this._lobbyPanelHeight + 12.0 * uiScale + : 72.0 * uiScale; + // Near the top, but not flush against the window edge. + this._lobbyBeheadedRoot.y = screenPad * 0.85 + belowCode; + + for (int i = 0; i < slotCount; i++) + { + var slot = i < slots.Count ? slots[i] : _ConnectionUI.LobbyPlayerSlot.Empty; + bool occupied = slot.Occupied && !slot.IsConnecting; + double slotX = i * step; + // Beheaded stays on the geometric slot center — do not nudge these. + double sprX = slotX + slotW * 0.5; + double sprY = slotH * 0.5; + double plateX = slotX + plateDX; + double plateY = plateDY; + + var plate = new Graphics(this._lobbyBeheadedRoot); + UiChrome.DrawRaisedPlate( + plate, + plateX, + plateY, + slotW, + slotH, + FieldCornerRadius * uiScale, + occupied && slot.IsHost ? 0x3A5A7E : PanelInnerEdge, + occupied ? PanelInner : 0x1A2233, + occupied && slot.IsHost ? 0x4A6A8E : PanelInnerTop, + enabled: true); + + try + { + string skinId = occupied ? slot.Skin : DefaultLobbySkin; + if (string.IsNullOrWhiteSpace(skinId)) + skinId = DefaultLobbySkin; + + var spr = CreateLobbyBeheaded(skinId, i, scale); + if (spr == null) + { + Log.Warning("[ConnectionUI] Lobby beheaded[{Index}] create returned null", i); + } + else + { + this._lobbyBeheadedRoot.addChild(spr); + spr.x = sprX; + spr.y = sprY; + if (!occupied) + ApplyLobbyBeheadedSilhouette(spr); + this.sprites.Add(spr); + } + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] Lobby beheaded[{Index}] failed: {Message}", i, ex.Message); + } + + string nick = ResolveLobbySlotNick(slot); + if (string.IsNullOrWhiteSpace(nick)) + continue; + + var nickText = Assets.Class.makeText( + nick.AsHaxeString(), + Tools.MultiColor.ColorFromHex(slot.IsConnecting ? "#9098a8" : "#e8eef7"), + false, + this._lobbyBeheadedRoot); + double nickScale = 0.42 * textUi; + nickText.customScale = nickScale; + nickText.onResize(); + nickText.textColor = slot.IsConnecting ? HelpColor : (slot.IsYou ? 0xE8EEF7 : TextColor); + try + { + double textW = nickText.textWidth; + nickText.x = plateX + (slotW - textW) * 0.5; + } + catch + { + nickText.x = plateX + 4.0 * uiScale; + } + // Nick follows the box, not the geometric slot. + nickText.y = plateY + slotH + nickGap * uiScale; + this.connectionLabels.Add(nickText); + } + + try { this._lobbyBeheadedRoot.set_visible(true); } catch { } + } + + private static string ResolveLobbySlotNick(_ConnectionUI.LobbyPlayerSlot slot) + { + if (!slot.Occupied) + return string.Empty; + + if (slot.IsConnecting) + { + if (string.Equals(slot.Nick, _ConnectionUI.SteamLobbyConnectingMarker, StringComparison.Ordinal)) + return GetText.Instance.GetString("Connecting to Steam lobby..."); + return GetText.Instance.GetString("connecting..."); + } + + return slot.Nick; + } + + private HSprite? CreateLobbyBeheaded(string skinId, int index, double scale) + { + if (string.IsNullOrWhiteSpace(skinId)) + skinId = DefaultLobbySkin; + + string skinanim = index >= 0 && index < this.animlist.Count ? this.animlist[index] : "idle"; + virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_ skinInfo; + try + { + skinInfo = Cdb.Class.getSkinInfo(skinId.AsHaxeString()); + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] getSkinInfo({Skin}) failed: {Message}; using {Fallback}", skinId, ex.Message, DefaultLobbySkin); + skinId = DefaultLobbySkin; + skinInfo = Cdb.Class.getSkinInfo(DefaultLobbySkin.AsHaxeString()); + } + + SpriteLib g = Assets.Class.getHeroLib(skinInfo); + if (g == null) + { + Log.Warning("[ConnectionUI] getHeroLib returned null for {Skin}", skinId); + return null; + } + + var spr = new HSprite(g, skinanim.AsHaxeString(), Ref.Null, null); + + SpritePivot pivot = spr.pivot; + // Center pivot so negative scaleX keeps the body in the middle of the plate. + pivot.centerFactorX = 0.5; + pivot.centerFactorY = 0.5; + pivot.usingFactor = true; + pivot.isUndefined = false; + + this.spriteui = spr; + // Must match title-screen hero linker cache: + // Base2d + ColorMap + DirLighted + NormalMap. ColorMap alone is NOT cached → invisible. + initColorMap(skinId, skinanim); + + AnimManager animManager = spr.get_anim().play(skinanim.AsHaxeString(), null, null).loop(null); + animManager.genSpeed = 0.4; + + double absScale = System.Math.Abs(scale); + // Default idle faces right; negative scaleX faces left. + spr.scaleX = -absScale; + spr.scaleY = absScale; + spr.set_visible(true); + return spr; + } + + private static void ApplyLobbyBeheadedSilhouette(HSprite spr) + { + try + { + var color = spr.color; + if (color == null) + return; + // Dark silhouette, not pure black (pure black disappears on navy plates). + color.x = 0.18; + color.y = 0.18; + color.z = 0.22; + } + catch + { + } + } + + private void EnsureLobbyBeheadedLighting() + { + try + { + _ = new MainPageLightingInitializer(this); + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] lobby lighting init failed: {Message}", ex.Message); + } + } + + public void playallanims(HSprite hSprite) + { + try + { + var groups = hSprite.lib?.groups; + if (groups == null) + return; + + var keysIterator = groups.keys(); + animlist.Clear(); + + while (keysIterator.hasNext()) + { + string key = keysIterator.next().ToString(); + if (!key.StartsWith("Atk", StringComparison.OrdinalIgnoreCase)) + animlist.Add(key); + } + } + catch + { + } + } + + /// + /// Title-screen beheaded path: ColorMap + DirLighted + NormalMap. + /// Do not strip DirLighted/NormalMap — ColorMap alone is missing from the shader cache. + /// + public void initColorMap(string colorMap, string? animGroup = null) + { + if (this.spriteui == null) + return; + + string skinId = string.IsNullOrWhiteSpace(colorMap) ? DefaultLobbySkin : colorMap; + try + { + dc.shader.ColorMap existing = (dc.shader.ColorMap)this.spriteui.getShader(dc.shader.ColorMap.Class); + if (existing != null) + this.spriteui.removeShader(existing); + + DirLighted existingLight = (DirLighted)this.spriteui.getShader(DirLighted.Class); + if (existingLight != null) + this.spriteui.removeShader(existingLight); + + NormalMap existingNormal = (NormalMap)this.spriteui.getShader(NormalMap.Class); + if (existingNormal != null) + this.spriteui.removeShader(existingNormal); + } + catch + { + } + + try + { + var skinInfo = Cdb.Class.getSkinInfo(skinId.AsHaxeString()); + dc.h3d.mat.Texture heroColorMap = Assets.Class.getHeroColorMap(skinInfo); + if (heroColorMap == null) + { + Log.Warning("[ConnectionUI] getHeroColorMap returned null for {Skin}", skinId); + return; + } + + this.spriteui.addShader(new dc.shader.ColorMap(heroColorMap)); + this.spriteui.addShader(new DirLighted()); + + dc.h3d.mat.Texture? normalMap = null; + try + { + string group = string.IsNullOrWhiteSpace(animGroup) ? "idle" : animGroup; + normalMap = this.spriteui.lib?.getNormalMapFromGroup(group.AsHaxeString()); + } + catch + { + try { normalMap = this.spriteui.lib?.getNormalMapFromSprite(this.spriteui); } catch { } + } + + if (normalMap != null) + this.spriteui.addShader(new NormalMap(normalMap)); + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] initColorMap({Skin}) failed: {Message}", skinId, ex.Message); + } + } + } +} diff --git a/UI/ConnectionUI/ConnectionUI.TextPrompt.cs b/UI/ConnectionUI/ConnectionUI.TextPrompt.cs new file mode 100644 index 0000000..1a99cd0 --- /dev/null +++ b/UI/ConnectionUI/ConnectionUI.TextPrompt.cs @@ -0,0 +1,1122 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using dc; +using dc.h2d; +using dc.hxd; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Modules; +using ModCore.Utilities; +using Serilog; +using DeadCellsMultiplayerMod.Tools; + +namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection +{ + /// + /// Styled text-prompt overlay for ConnectionUI (replaces the stock ugly TextInput dialog). + /// Supports caret movement, selection highlight, and clipboard. + /// + public partial class ConnectionUI + { + private const int KeyBackspace = 8; + private const int KeyTab = 9; + private const int KeyEnter = 13; + private const int KeyEscape = 27; + private const int KeySpace = 32; + private const int KeyCtrl = 17; + private const int KeyLCtrl = 162; + private const int KeyRCtrl = 163; + private const int KeyShift = 16; + private const int KeyLShift = 160; + private const int KeyRShift = 161; + private const int KeyLeft = 37; + private const int KeyUp = 38; + private const int KeyRight = 39; + private const int KeyDown = 40; + private const int KeyHome = 36; + private const int KeyEnd = 35; + private const int KeyDelete = 46; + private const int KeyA = 65; + private const int KeyC = 67; + private const int KeyV = 86; + private const int KeyX = 88; + private const int CfUnicodeText = 13; + private const uint GmemMoveable = 0x0002; + + private static readonly int PromptPlaceholderColor = 0x6E778A; + private static readonly int PromptValueColor = 0xE8EEF7; + private static readonly int PromptCaretColor = 0x59D5FF; + private static readonly int PromptSelectionColor = 0x2F6FA8; + private static readonly int PromptFieldBorder = 0x46546F; + private static readonly int PromptFieldFace = 0x0C121D; + + private dc.h2d.Object? _promptRoot; + private dc.h2d.Object? _promptFieldParent; + private dc.ui.Text? _promptValueText; + private dc.ui.Text? _promptPlaceholderText; + private dc.ui.Text? _promptMeasureText; + private Graphics? _promptSelectionGfx; + private Graphics? _promptCaretGfx; + private string _promptBuffer = string.Empty; + private string _promptTitle = string.Empty; + private string _promptPlaceholder = string.Empty; + private string _promptRenderedBuffer = "\u0001"; + private int _promptCaret; + private int _promptSelStart; + private int _promptSelEnd; + private int _promptRenderedCaret = int.MinValue; + private int _promptRenderedSelStart = int.MinValue; + private int _promptRenderedSelEnd = int.MinValue; + private bool _promptRenderedCaretVisible = true; + private bool _promptNoSpaces; + private bool _promptOpen; + private Action? _promptOnOk; + private Action? _promptOnCancel; + private HlAction? _promptWindowHandler; + private double _promptCaretBlink; + private bool _promptBackspaceHandledFrame; + private bool _promptCharFromEventFrame; + private double _promptTextUi; + private double _promptFieldTextX; + private double _promptFieldTextY; + private double _promptFieldX; + private double _promptFieldY; + private double _promptFieldW; + private double _promptFieldH; + private double _promptValueScale; + + /// Opens a styled text prompt centered on screen. + public static void ShowTextPrompt( + string title, + string initial, + Action onOk, + Action? onCancel = null, + bool noSpaces = false) + { + var instance = TryGetLiveInstance(); + if (instance == null) + return; + set_visible = true; + instance.OpenTextPrompt(title, initial ?? string.Empty, onOk, onCancel, noSpaces); + } + + public static bool IsTextPromptOpen() + { + var instance = TryGetLiveInstance(); + return instance != null && instance._promptOpen; + } + + private void OpenTextPrompt( + string title, + string initial, + Action onOk, + Action? onCancel, + bool noSpaces) + { + CloseTextPrompt(apply: false); + + if (noSpaces && initial.Contains(' ', StringComparison.Ordinal)) + initial = initial.Replace(" ", string.Empty, StringComparison.Ordinal); + + this._promptTitle = title ?? string.Empty; + this._promptPlaceholder = string.IsNullOrWhiteSpace(title) ? "…" : title.Trim(); + this._promptBuffer = initial ?? string.Empty; + this._promptCaret = this._promptBuffer.Length; + this._promptSelStart = 0; + this._promptSelEnd = this._promptBuffer.Length; // open with full selection (standard dialog UX) + this._promptNoSpaces = noSpaces; + this._promptOnOk = onOk; + this._promptOnCancel = onCancel; + this._promptOpen = true; + this._promptCaretBlink = 0; + this._promptCharFromEventFrame = false; + this._promptRenderedBuffer = "\u0001"; + this._promptRenderedCaret = int.MinValue; + this._promptRenderedSelStart = int.MinValue; + this._promptRenderedSelEnd = int.MinValue; + + EnsurePromptWindowHook(); + SetHxdTextInput(enabled: true); + RebuildTextPromptUi(); + } + + private void CloseTextPrompt(bool apply) + { + SetHxdTextInput(enabled: false); + + if (!this._promptOpen && this._promptRoot == null) + return; + + var onOk = this._promptOnOk; + var onCancel = this._promptOnCancel; + var value = this._promptBuffer; + + this._promptOpen = false; + this._promptOnOk = null; + this._promptOnCancel = null; + this._promptValueText = null; + this._promptPlaceholderText = null; + this._promptMeasureText = null; + this._promptSelectionGfx = null; + this._promptCaretGfx = null; + this._promptFieldParent = null; + this._promptRenderedBuffer = "\u0001"; + + try { this._promptRoot?.remove(); } catch { } + this._promptRoot = null; + + if (apply) + { + try { onOk?.Invoke(value); } catch (Exception ex) { Log.Debug("[ConnectionUI] TextPrompt OK failed: {Message}", ex.Message); } + } + else + { + try { onCancel?.Invoke(); } catch { } + } + } + + private static void SetHxdTextInput(bool enabled) + { + try + { + var win = Window.Class.getInstance(); + if (win == null) + return; + + var type = win.GetType(); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public + | BindingFlags.NonPublic | BindingFlags.IgnoreCase; + + if (!enabled) + { + foreach (var name in new[] { "stopTextInput", "textInputStop", "endTextInput" }) + { + if (TryInvokeNoArgs(win, type, name, flags)) + return; + } + return; + } + + foreach (var name in new[] { "startTextInput", "setTextInput", "textInputStart", "beginTextInput" }) + { + if (TryInvokeNoArgs(win, type, name, flags)) + return; + + foreach (var method in type.GetMethods(flags)) + { + if (!string.Equals(method.Name, name, StringComparison.OrdinalIgnoreCase)) + continue; + var ps = method.GetParameters(); + if (ps.Length == 0) + continue; + try + { + object?[] args = new object?[ps.Length]; + for (int i = 0; i < ps.Length; i++) + args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : CreateDefaultArg(ps[i].ParameterType); + method.Invoke(win, args); + return; + } + catch + { + } + } + } + } + catch + { + } + } + + private static bool TryInvokeNoArgs(object win, System.Type type, string name, BindingFlags flags) + { + try + { + var method = type.GetMethod(name, flags, binder: null, types: System.Type.EmptyTypes, modifiers: null) + ?? type.GetMethod(name, flags); + if (method == null || method.GetParameters().Length != 0) + return false; + method.Invoke(win, null); + return true; + } + catch + { + return false; + } + } + + private static object? CreateDefaultArg(System.Type t) + { + if (t == typeof(int) || t == typeof(uint) || t == typeof(short) || t == typeof(byte)) + return 0; + if (t == typeof(double) || t == typeof(float)) + return 0.0; + if (t == typeof(bool)) + return false; + if (t.IsValueType) + { + try { return Activator.CreateInstance(t); } catch { return null; } + } + try + { + var ctors = t.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (var ctor in ctors) + { + var cps = ctor.GetParameters(); + if (cps.Length == 4 + && cps[0].ParameterType == typeof(int) + && cps[1].ParameterType == typeof(int) + && cps[2].ParameterType == typeof(int) + && cps[3].ParameterType == typeof(int)) + { + return ctor.Invoke(new object[] { 0, 0, 400, 40 }); + } + if (cps.Length == 4 + && (cps[0].ParameterType == typeof(double) || cps[0].ParameterType == typeof(float))) + { + return ctor.Invoke(new object[] { 0.0, 0.0, 400.0, 40.0 }); + } + } + var empty = t.GetConstructor(System.Type.EmptyTypes); + if (empty != null) + return empty.Invoke(null); + } + catch + { + } + return null; + } + + private void EnsurePromptWindowHook() + { + if (this._promptWindowHandler != null) + return; + try + { + var win = Window.Class.getInstance(); + if (win == null) + return; + this._promptWindowHandler = new HlAction(OnPromptWindowEvent); + win.addEventTarget(this._promptWindowHandler); + } + catch + { + } + } + + private bool PromptHasSelection => this._promptSelEnd > this._promptSelStart; + + private void ClampPromptCaretState() + { + int len = this._promptBuffer?.Length ?? 0; + if (this._promptCaret < 0) this._promptCaret = 0; + if (this._promptCaret > len) this._promptCaret = len; + if (this._promptSelStart < 0) this._promptSelStart = 0; + if (this._promptSelEnd < 0) this._promptSelEnd = 0; + if (this._promptSelStart > len) this._promptSelStart = len; + if (this._promptSelEnd > len) this._promptSelEnd = len; + if (this._promptSelStart > this._promptSelEnd) + { + int t = this._promptSelStart; + this._promptSelStart = this._promptSelEnd; + this._promptSelEnd = t; + } + } + + private void ClearPromptSelectionToCaret() + { + this._promptSelStart = this._promptCaret; + this._promptSelEnd = this._promptCaret; + } + + private void SelectPromptAll() + { + this._promptSelStart = 0; + this._promptSelEnd = this._promptBuffer.Length; + this._promptCaret = this._promptSelEnd; + this._promptCaretBlink = 0; + } + + private int _promptSelAnchor = -1; + + private void HandlePromptCaretMove(int newIndex, bool extend) + { + int len = this._promptBuffer.Length; + if (newIndex < 0) newIndex = 0; + if (newIndex > len) newIndex = len; + + if (extend) + { + if (this._promptSelAnchor < 0) + this._promptSelAnchor = this._promptCaret; + this._promptCaret = newIndex; + this._promptSelStart = System.Math.Min(this._promptSelAnchor, this._promptCaret); + this._promptSelEnd = System.Math.Max(this._promptSelAnchor, this._promptCaret); + } + else + { + this._promptSelAnchor = -1; + this._promptCaret = newIndex; + ClearPromptSelectionToCaret(); + } + + this._promptCaretBlink = 0; + ClampPromptCaretState(); + RefreshPromptValueText(); + } + + private string GetPromptSelectedText() + { + ClampPromptCaretState(); + if (!PromptHasSelection) + return string.Empty; + return this._promptBuffer.Substring(this._promptSelStart, this._promptSelEnd - this._promptSelStart); + } + + private void DeletePromptSelection() + { + ClampPromptCaretState(); + if (!PromptHasSelection) + return; + this._promptBuffer = this._promptBuffer.Remove(this._promptSelStart, this._promptSelEnd - this._promptSelStart); + this._promptCaret = this._promptSelStart; + this._promptSelAnchor = -1; + ClearPromptSelectionToCaret(); + } + + private void InsertPromptText(string text) + { + if (string.IsNullOrEmpty(text)) + return; + if (this._promptNoSpaces) + text = text.Replace(" ", string.Empty, StringComparison.Ordinal); + if (text.Length == 0) + return; + + if (PromptHasSelection) + DeletePromptSelection(); + + ClampPromptCaretState(); + this._promptBuffer = this._promptBuffer.Insert(this._promptCaret, text); + this._promptCaret += text.Length; + this._promptSelAnchor = -1; + ClearPromptSelectionToCaret(); + this._promptCaretBlink = 0; + RefreshPromptValueText(); + } + + private void RemovePromptBackward() + { + // EKeyDown + ETextInput (WM_CHAR 8) + Tick isPressed can all see one Backspace. + if (this._promptBackspaceHandledFrame) + return; + this._promptBackspaceHandledFrame = true; + if (PromptHasSelection) + { + DeletePromptSelection(); + RefreshPromptValueText(); + return; + } + if (this._promptCaret <= 0 || this._promptBuffer.Length == 0) + return; + this._promptBuffer = this._promptBuffer.Remove(this._promptCaret - 1, 1); + this._promptCaret--; + ClearPromptSelectionToCaret(); + this._promptCaretBlink = 0; + RefreshPromptValueText(); + } + + private void RemovePromptForward() + { + if (this._promptBackspaceHandledFrame) + return; + this._promptBackspaceHandledFrame = true; + if (PromptHasSelection) + { + DeletePromptSelection(); + RefreshPromptValueText(); + return; + } + if (this._promptCaret >= this._promptBuffer.Length) + return; + this._promptBuffer = this._promptBuffer.Remove(this._promptCaret, 1); + ClearPromptSelectionToCaret(); + this._promptCaretBlink = 0; + RefreshPromptValueText(); + } + + private void OnPromptWindowEvent(Event e) + { + if (!this._promptOpen || e?.kind == null) + return; + + if (e.kind.Index == EventKind.Indexes.ETextInput) + { + int code = e.charCode; + // Backspace/Delete/nav are handled only on EKeyDown (+ Tick fallback). + // Handling them here as well deleted two characters per keypress. + if (code < 32) + return; + if (code == KeyEnter || code == KeyEscape || code == KeyTab) + return; + + char ch = (char)code; + if (this._promptNoSpaces && ch == ' ') + return; + + this._promptCharFromEventFrame = true; + InsertPromptText(ch.ToString()); + return; + } + + if (e.kind.Index == EventKind.Indexes.EKeyDown) + { + int code = e.keyCode; + bool shift = IsPromptShiftDown(); + bool ctrl = IsPromptCtrlDown(); + + if (code == KeyEnter) + { + this._promptBackspaceHandledFrame = true; + CloseTextPrompt(apply: true); + } + else if (code == KeyEscape) + { + this._promptBackspaceHandledFrame = true; + CloseTextPrompt(apply: false); + } + else if (code == KeyBackspace) + { + RemovePromptBackward(); + } + else if (code == KeyDelete) + { + RemovePromptForward(); + } + else if (code == KeyLeft) + { + this._promptBackspaceHandledFrame = true; + HandlePromptCaretMove(this._promptCaret - 1, shift); + } + else if (code == KeyRight) + { + this._promptBackspaceHandledFrame = true; + HandlePromptCaretMove(this._promptCaret + 1, shift); + } + else if (code == KeyHome || code == KeyUp) + { + this._promptBackspaceHandledFrame = true; + HandlePromptCaretMove(0, shift); + } + else if (code == KeyEnd || code == KeyDown) + { + this._promptBackspaceHandledFrame = true; + HandlePromptCaretMove(this._promptBuffer.Length, shift); + } + else if (ctrl && code == KeyA) + { + this._promptBackspaceHandledFrame = true; + this._promptSelAnchor = 0; + SelectPromptAll(); + RefreshPromptValueText(); + } + } + } + + private static bool IsPromptShiftDown() + { + return Key.Class.isDown(KeyShift) || Key.Class.isDown(KeyLShift) || Key.Class.isDown(KeyRShift); + } + + private static bool IsPromptCtrlDown() + { + return Key.Class.isDown(KeyCtrl) || Key.Class.isDown(KeyLCtrl) || Key.Class.isDown(KeyRCtrl); + } + + private void TickTextPrompt() + { + if (!this._promptOpen) + return; + + this._promptCaretBlink += 0.05; + if (this._promptCaretBlink > 1.0) + this._promptCaretBlink = 0; + + try + { + bool ctrl = IsPromptCtrlDown(); + + // Backspace / Delete / arrows / Enter / Escape are handled only in + // OnPromptWindowEvent (EKeyDown). Polling them here again deleted two + // characters (or moved the caret twice) whenever both paths saw the press. + + if (ctrl && Key.Class.isPressed(KeyA)) + { + this._promptSelAnchor = 0; + SelectPromptAll(); + RefreshPromptValueText(); + } + else if (ctrl && Key.Class.isPressed(KeyC)) + { + var selected = PromptHasSelection ? GetPromptSelectedText() : this._promptBuffer; + TrySetClipboardText(selected ?? string.Empty); + } + else if (ctrl && Key.Class.isPressed(KeyX)) + { + var selected = PromptHasSelection ? GetPromptSelectedText() : this._promptBuffer; + TrySetClipboardText(selected ?? string.Empty); + if (PromptHasSelection) + { + DeletePromptSelection(); + RefreshPromptValueText(); + } + else + { + this._promptBuffer = string.Empty; + this._promptCaret = 0; + ClearPromptSelectionToCaret(); + RefreshPromptValueText(); + } + } + else if (ctrl && Key.Class.isPressed(KeyV)) + { + var clip = TryGetClipboardText(); + if (!string.IsNullOrEmpty(clip)) + InsertPromptText(clip); + } + else if (!ctrl && !this._promptCharFromEventFrame) + { + PollPromptTypedKeys(); + } + } + catch + { + } + + this._promptCharFromEventFrame = false; + this._promptBackspaceHandledFrame = false; + RefreshPromptValueText(blink: true); + } + + private void PollPromptTypedKeys() + { + bool shift = IsPromptShiftDown(); + + if (!this._promptNoSpaces && Key.Class.isPressed(KeySpace)) + { + InsertPromptText(" "); + return; + } + + for (int k = 65; k <= 90; k++) + { + if (!Key.Class.isPressed(k)) + continue; + char ch = shift ? (char)k : (char)(k + 32); + InsertPromptText(ch.ToString()); + return; + } + + if (Key.Class.isPressed(48)) { InsertPromptText(shift ? ")" : "0"); return; } + if (Key.Class.isPressed(49)) { InsertPromptText(shift ? "!" : "1"); return; } + if (Key.Class.isPressed(50)) { InsertPromptText(shift ? "@" : "2"); return; } + if (Key.Class.isPressed(51)) { InsertPromptText(shift ? "#" : "3"); return; } + if (Key.Class.isPressed(52)) { InsertPromptText(shift ? "$" : "4"); return; } + if (Key.Class.isPressed(53)) { InsertPromptText(shift ? "%" : "5"); return; } + if (Key.Class.isPressed(54)) { InsertPromptText(shift ? "^" : "6"); return; } + if (Key.Class.isPressed(55)) { InsertPromptText(shift ? "&" : "7"); return; } + if (Key.Class.isPressed(56)) { InsertPromptText(shift ? "*" : "8"); return; } + if (Key.Class.isPressed(57)) { InsertPromptText(shift ? "(" : "9"); return; } + + for (int k = 96; k <= 105; k++) + { + if (!Key.Class.isPressed(k)) + continue; + InsertPromptText(((char)('0' + (k - 96))).ToString()); + return; + } + + if (Key.Class.isPressed(186)) { InsertPromptText(shift ? ":" : ";"); return; } + if (Key.Class.isPressed(187)) { InsertPromptText(shift ? "+" : "="); return; } + if (Key.Class.isPressed(188)) { InsertPromptText(shift ? "<" : ","); return; } + if (Key.Class.isPressed(189)) { InsertPromptText(shift ? "_" : "-"); return; } + if (Key.Class.isPressed(190)) { InsertPromptText(shift ? ">" : "."); return; } + if (Key.Class.isPressed(191)) { InsertPromptText(shift ? "?" : "/"); return; } + if (Key.Class.isPressed(192)) { InsertPromptText(shift ? "~" : "`"); return; } + if (Key.Class.isPressed(219)) { InsertPromptText(shift ? "{" : "["); return; } + if (Key.Class.isPressed(220)) { InsertPromptText(shift ? "|" : "\\"); return; } + if (Key.Class.isPressed(221)) { InsertPromptText(shift ? "}" : "]"); return; } + if (Key.Class.isPressed(222)) { InsertPromptText(shift ? "\"" : "'"); return; } + if (Key.Class.isPressed(110)) { InsertPromptText("."); return; } + } + + private void RebuildTextPromptUi() + { + try { this._promptRoot?.remove(); } catch { } + this._promptRoot = new dc.h2d.Object(null); + this.root.addChild(this._promptRoot); + + var win = Window.Class.getInstance(); + double screenW = win?.get_width() ?? 1280; + double screenH = win?.get_height() ?? 720; + var uiScale = UiScale.GetResolutionScale(); + var textUi = System.Math.Max(uiScale, 1.0) * GetWindowedTextBoost(); + this._promptTextUi = textUi; + this._promptValueScale = 0.92 * textUi; + + double panelW = System.Math.Min(screenW * 0.56, 720.0 * uiScale); + if (panelW < 440) + panelW = System.Math.Min(440, screenW - 40); + double pad = 24.0 * uiScale; + double titleH = 32.0 * uiScale; + double fieldH = 68.0 * uiScale; + double btnH = 56.0 * uiScale; + double gap = 20.0 * uiScale; + double panelH = pad + titleH + gap + fieldH + gap + btnH + pad; + + var dim = new Graphics(this._promptRoot); + int dimColor = 0x000000; + double dimA = 0.55; + dim.beginFill(Ref.From(ref dimColor), Ref.From(ref dimA)); + dim.drawRect(0, 0, screenW, screenH); + dim.endFill(); + var dimHit = new dc.h2d.Interactive(screenW, screenH, this._promptRoot, null); + dimHit.x = 0; + dimHit.y = 0; + + var card = new dc.h2d.Object(this._promptRoot); + card.x = (screenW - panelW) * 0.5; + card.y = (screenH - panelH) * 0.5; + this._promptFieldParent = card; + + var block = new dc.h2d.Interactive(panelW, panelH, card, null); + block.x = 0; + block.y = 0; + + var g = new Graphics(card); + UiChrome.DrawSoftShadow(g, 0, 0, panelW, panelH, CardCornerRadius * uiScale, offsetX: 5.0, offsetY: 8.0); + UiChrome.FillRoundRect(g, 0, 0, panelW, panelH, CardCornerRadius * uiScale, PanelInnerEdge, 0.85); + UiChrome.FillRoundRect( + g, + 1.5, + 1.5, + panelW - 3.0, + panelH - 3.0, + System.Math.Max(0.0, CardCornerRadius * uiScale - 1.5), + PanelInner, + 0.98); + UiChrome.FillRoundRect(g, 0, 0, panelW, panelH, CardCornerRadius * uiScale, AccentColor, 0.18); + // Re-draw face so the cyan is only a soft outer lip, not a full tint. + UiChrome.FillRoundRect( + g, + 2.0, + 2.0, + panelW - 4.0, + panelH - 4.0, + System.Math.Max(0.0, CardCornerRadius * uiScale - 2.0), + PanelInner, + 0.98); + + var title = Assets.Class.makeText( + this._promptTitle.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#f7fc65"), + false, + card); + title.customScale = 0.58 * textUi; + title.onResize(); + title.textColor = 0xF7FC65; + CenterMenuText(title, this._promptTitle, pad, panelW - pad * 2, 0.58 * textUi); + title.y = pad; + + double fieldX = pad; + double fieldY = pad + titleH + gap; + double fieldW = panelW - pad * 2; + DrawPromptFieldBox(g, fieldX, fieldY, fieldW, fieldH); + + this._promptFieldX = fieldX; + this._promptFieldY = fieldY; + this._promptFieldW = fieldW; + this._promptFieldH = fieldH; + this._promptFieldTextX = fieldX + 16.0 * uiScale; + // Optical vertical center — dc.ui.Text.textHeight is inflated and pushes glyphs down. + this._promptFieldTextY = GetPromptTextY(); + + this._promptSelectionGfx = new Graphics(card); + + this._promptPlaceholderText = Assets.Class.makeText( + this._promptPlaceholder.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#6e778a"), + false, + card); + this._promptPlaceholderText.customScale = this._promptValueScale; + this._promptPlaceholderText.onResize(); + this._promptPlaceholderText.textColor = PromptPlaceholderColor; + this._promptPlaceholderText.x = this._promptFieldTextX; + this._promptPlaceholderText.y = this._promptFieldTextY; + + this._promptMeasureText = Assets.Class.makeText( + "".AsHaxeString(), + Tools.MultiColor.ColorFromHex("#ffffff"), + false, + card); + this._promptMeasureText.customScale = this._promptValueScale; + this._promptMeasureText.onResize(); + this._promptMeasureText.x = -10000; + this._promptMeasureText.y = -10000; + try { this._promptMeasureText.set_visible(false); } catch { } + + this._promptValueText = null; + this._promptCaretGfx = null; + this._promptRenderedBuffer = "\u0001"; + this._promptRenderedCaret = int.MinValue; + this._promptRenderedSelStart = int.MinValue; + this._promptRenderedSelEnd = int.MinValue; + RefreshPromptValueText(); + + // Caret above glyphs. + this._promptCaretGfx = new Graphics(card); + this._promptRenderedCaret = int.MinValue; + RefreshPromptValueText(); + + double btnW = (fieldW - 12.0 * uiScale) * 0.5; + double btnY = panelH - pad - btnH; + PlacePromptButton(GetText.Instance.GetString("OK"), fieldX, btnY, btnW, btnH, textUi, uiScale, () => CloseTextPrompt(apply: true), card); + PlacePromptButton(GetText.Instance.GetString("Cancel"), fieldX + btnW + 12.0 * uiScale, btnY, btnW, btnH, textUi, uiScale, () => CloseTextPrompt(apply: false), card); + } + + private static void DrawPromptFieldBox(Graphics g, double x, double y, double w, double h) + { + double uiScale = UiScale.GetResolutionScale(); + UiChrome.DrawInsetWell( + g, + x, + y, + w, + h, + FieldCornerRadius * uiScale, + PromptFieldBorder, + PromptFieldFace, + enabled: true); + } + + private void PlacePromptButton( + string label, + double x, + double y, + double w, + double h, + double textUi, + double uiScale, + Action onClick, + dc.h2d.Object parent) + { + var g = new Graphics(parent); + UiChrome.DrawRaisedPlate( + g, + x, + y, + w, + h, + ButtonCornerRadius * uiScale, + PanelInnerEdge, + PanelInner, + PanelInnerTop, + enabled: true); + + var text = Assets.Class.makeText( + label.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#ffffff"), + false, + parent); + double scale = 0.78 * textUi; + text.customScale = scale; + text.onResize(); + text.textColor = TextColor; + CenterMenuText(text, label, x, w, scale); + text.y = y + (h - 22.0 * uiScale) * 0.5; + + var hit = new dc.h2d.Interactive(w, h, parent, null); + hit.x = x; + hit.y = y; + Graphics? hover = null; + hit.onOver = new HlAction(_ => + { + try { hover?.remove(); } catch { } + hover = new Graphics(parent); + UiChrome.DrawHoverRing( + hover, + x, + y, + w, + h, + ButtonCornerRadius * uiScale, + HoverBorderColor, + PanelInner); + // Keep the ring under the label so text stays visible. + int textIndex = 0; + try { textIndex = parent.getChildIndex(text); } catch { textIndex = 0; } + if (textIndex < 0) + textIndex = 0; + parent.addChildAt(hover, textIndex); + }); + hit.onOut = new HlAction(_ => + { + try { hover?.remove(); } catch { } + hover = null; + }); + hit.onClick = new HlAction(_ => + { + try { hover?.remove(); } catch { } + onClick(); + }); + } + + /// + /// Optical Y for prompt glyphs inside the field. Do not use textHeight — DC fonts + /// report a tall box and the label ends up sitting too low (extra empty space on top). + /// + private double GetPromptTextY() + { + // Same empty-ascent compensation as menu textblocks. + double boxH = 30.0 * System.Math.Max(this._promptValueScale, 0.4); + const double emptyAscentFrac = 0.38; + double inkTopInBox = boxH * emptyAscentFrac; + double inkH = boxH * (1.0 - emptyAscentFrac); + return this._promptFieldY + (this._promptFieldH - inkH) * 0.5 - inkTopInBox; + } + + private double MeasurePromptTextWidth(string text) + { + if (string.IsNullOrEmpty(text)) + return 0; + try + { + if (this._promptMeasureText == null) + return text.Length * 11.0 * this._promptValueScale; + + this._promptMeasureText.set_text(text.AsHaxeString()); + this._promptMeasureText.customScale = this._promptValueScale; + this._promptMeasureText.onResize(); + double w = this._promptMeasureText.textWidth; + if (this._promptMeasureText.scaleX > 0.01) + w *= this._promptMeasureText.scaleX; + else + w *= this._promptValueScale; + return System.Math.Max(0, w); + } + catch + { + return text.Length * 11.0 * this._promptValueScale; + } + } + + private void RefreshPromptValueText(bool blink = false) + { + if (this._promptFieldParent == null) + return; + + ClampPromptCaretState(); + string buffer = this._promptBuffer ?? string.Empty; + bool empty = string.IsNullOrEmpty(buffer); + bool showCaret = (!blink || this._promptCaretBlink < 0.5) && !PromptHasSelection; + + if (this._promptPlaceholderText != null) + { + try + { + this._promptFieldTextY = GetPromptTextY(); + this._promptPlaceholderText.customScale = this._promptValueScale; + this._promptPlaceholderText.onResize(); + this._promptPlaceholderText.x = this._promptFieldTextX; + this._promptPlaceholderText.y = this._promptFieldTextY; + this._promptPlaceholderText.set_visible(empty && !PromptHasSelection); + } + catch + { + try { this._promptPlaceholderText.visible = empty; } catch { } + } + } + + bool bufferChanged = !string.Equals(this._promptRenderedBuffer, buffer, StringComparison.Ordinal); + bool caretChanged = this._promptRenderedCaret != this._promptCaret + || this._promptRenderedCaretVisible != showCaret; + bool selChanged = this._promptRenderedSelStart != this._promptSelStart + || this._promptRenderedSelEnd != this._promptSelEnd; + + if (!bufferChanged && !caretChanged && !selChanged && this._promptValueText != null) + return; + + this._promptRenderedBuffer = buffer; + this._promptRenderedCaret = this._promptCaret; + this._promptRenderedSelStart = this._promptSelStart; + this._promptRenderedSelEnd = this._promptSelEnd; + this._promptRenderedCaretVisible = showCaret; + + // Selection highlight behind the glyphs. + try { this._promptSelectionGfx?.clear(); } catch { } + if (this._promptSelectionGfx != null && PromptHasSelection) + { + string before = buffer.Substring(0, this._promptSelStart); + string selected = buffer.Substring(this._promptSelStart, this._promptSelEnd - this._promptSelStart); + double x0 = this._promptFieldTextX + MeasurePromptTextWidth(before); + double selW = System.Math.Max(4.0, MeasurePromptTextWidth(selected)); + double padY = 6.0; + int col = PromptSelectionColor; + double a = 0.55; + this._promptSelectionGfx.beginFill(Ref.From(ref col), Ref.From(ref a)); + this._promptSelectionGfx.drawRect( + x0, + this._promptFieldY + padY, + selW, + this._promptFieldH - padY * 2.0); + this._promptSelectionGfx.endFill(); + } + + if (bufferChanged || this._promptValueText == null) + { + try { this._promptValueText?.remove(); } catch { } + this._promptValueText = Assets.Class.makeText( + buffer.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#e8eef7"), + false, + this._promptFieldParent); + this._promptValueText.customScale = this._promptValueScale; + this._promptValueText.onResize(); + this._promptValueText.textColor = PromptValueColor; + this._promptValueText.x = this._promptFieldTextX; + this._promptValueText.y = this._promptFieldTextY; + } + + try + { + this._promptFieldTextY = GetPromptTextY(); + this._promptValueText!.set_text(buffer.AsHaxeString()); + this._promptValueText.customScale = this._promptValueScale; + this._promptValueText.textColor = PromptValueColor; + this._promptValueText.onResize(); + this._promptValueText.x = this._promptFieldTextX; + this._promptValueText.y = this._promptFieldTextY; + this._promptValueText.set_visible(!empty); + } + catch { } + + // Keep value under caret; selection gfx was created earlier so it stays behind. + try { if (this._promptValueText != null) this._promptFieldParent.addChild(this._promptValueText); } catch { } + try { if (this._promptCaretGfx != null) this._promptFieldParent.addChild(this._promptCaretGfx); } catch { } + + // Caret bar (not part of the string — so arrow movement is visible). + try { this._promptCaretGfx?.clear(); } catch { } + if (this._promptCaretGfx != null && showCaret) + { + string beforeCaret = buffer.Substring(0, this._promptCaret); + double caretX = this._promptFieldTextX + MeasurePromptTextWidth(beforeCaret); + double caretH = this._promptFieldH - 16.0; + double caretY = this._promptFieldY + 8.0; + int col = PromptCaretColor; + double a = 1.0; + this._promptCaretGfx.beginFill(Ref.From(ref col), Ref.From(ref a)); + this._promptCaretGfx.drawRect(caretX, caretY, 2.0, caretH); + this._promptCaretGfx.endFill(); + } + } + + private static string? TryGetClipboardText() + { + try + { + if (!IsClipboardFormatAvailable(CfUnicodeText)) + return null; + if (!OpenClipboard(IntPtr.Zero)) + return null; + try + { + var handle = GetClipboardData(CfUnicodeText); + if (handle == IntPtr.Zero) + return null; + var ptr = GlobalLock(handle); + if (ptr == IntPtr.Zero) + return null; + try { return Marshal.PtrToStringUni(ptr); } + finally { GlobalUnlock(handle); } + } + finally { CloseClipboard(); } + } + catch { return null; } + } + + private static bool TrySetClipboardText(string text) + { + try + { + if (!OpenClipboard(IntPtr.Zero)) + return false; + try + { + if (!EmptyClipboard()) + return false; + var bytes = (text.Length + 1) * 2; + var hGlobal = GlobalAlloc(GmemMoveable, (UIntPtr)bytes); + if (hGlobal == IntPtr.Zero) + return false; + var target = GlobalLock(hGlobal); + if (target == IntPtr.Zero) + { + GlobalFree(hGlobal); + return false; + } + try + { + Marshal.Copy(text.ToCharArray(), 0, target, text.Length); + Marshal.WriteInt16(target, text.Length * 2, 0); + } + finally { GlobalUnlock(hGlobal); } + + if (SetClipboardData(CfUnicodeText, hGlobal) == IntPtr.Zero) + { + GlobalFree(hGlobal); + return false; + } + return true; + } + finally { CloseClipboard(); } + } + catch { return false; } + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(IntPtr hWndNewOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EmptyClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr GetClipboardData(uint uFormat); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalLock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalUnlock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalFree(IntPtr hMem); + } +} diff --git a/UI/ConnectionUI/ConnectionUI.cs b/UI/ConnectionUI/ConnectionUI.cs index dfd0694..0465a71 100644 --- a/UI/ConnectionUI/ConnectionUI.cs +++ b/UI/ConnectionUI/ConnectionUI.cs @@ -17,27 +17,120 @@ namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection { - public class ConnectionUI : + /// + /// Visual hub for the multiplayer menu. LobbySession keeps all networking/state logic; this + /// Process renders the pretty button screens (host/join LAN & Steam, lobby status, errors). + /// LobbySession feeds it through // + /// /, and toggles the lobby display via + /// . + /// + public partial class ConnectionUI : Process, IEventReceiver { + // ---------------------------------------------------------------- palette + private static readonly int PanelInner = 0x14161F; + private static readonly int PanelInnerEdge = 0x2A3A5E; + private static readonly int PanelInnerTop = 0x3A4A6E; + private static readonly int AccentColor = 0x59D5FF; + private static readonly int TextColor = 0xC9C9C9; + private static readonly int HelpColor = 0x9098A8; + // Disabled stays in the navy/blue family — no neutral gray that fights the palette. + private static readonly int DisabledColor = 0x5A657C; + private static readonly int DisabledHelpColor = 0x4A5568; + private static readonly int DisabledPlateEdge = 0x1C2638; + private static readonly int DisabledPlateFace = 0x12161F; + private static readonly int DisabledPlateTop = 0x243044; + private static readonly int FieldFace = 0x0C121D; + private static readonly int FieldBorder = 0x46546F; + private static readonly int ContentCardFill = 0x10131C; + private static readonly int ContentCardEdge = 0x2E3F66; + private const double ButtonCornerRadius = 10.0; + private const double FieldCornerRadius = 8.0; + private const double CardCornerRadius = 16.0; + + internal enum UiMode + { + Lobby, + Menu + } + + // Dead Cells renders menu text smaller in windowed mode (the game's own pixelScale drops + // below 1.0). GhostHero compensates nicknames with a ~1.6x boost in windowed mode; we do + // the same so button labels stay as readable in a window as in fullscreen. + private const double WindowedTextBoost = 1.6; + private const int WindowedDisplayMode = 0; + private const int FullscreenDisplayMode = 1; + private const int BorderlessDisplayMode = 2; + private static int _cachedDisplayMode = int.MinValue; + private static int _cachedFullScreenMode = int.MinValue; + private static double _cachedTextBoost = 1.0; + + internal sealed class PendingButton + { + public string Label = string.Empty; + public string Help = string.Empty; + public bool Enabled = true; + public int Color = 0xFFFFFF; + /// Rendered as an editable input field (bordered box), not a button plate. + public bool FieldStyle; + public Action? OnClick; + } + + internal sealed class PendingInfo + { + public string Text = string.Empty; + public int Color = 0xFFFFFF; + } + + // ---------------------------------------------------------------- pending menu (fed by LobbySession) + private static readonly List PendingButtons = new(); + private static readonly List PendingInfos = new(); + + // ---------------------------------------------------------------- instance state private Flow? rootFlow; - private UIBox? bg; private dc.h2d.Interactive? inter; private Flow? spritesflow; - private Flow? MainTitleflow; private readonly List sprites = new(); private readonly List connectionLabels = new(); private readonly List lastConnections = new(); + private string lastLobbySlotsSignature = string.Empty; private Flow? lobbyCodeFlow; private dc.ui.Text? lobbyCodeTitleLabel; private dc.ui.Text? lobbyIdLabel; private string lastLobbyIdLabelText = string.Empty; + /// Top-right styled lobby players card. + private dc.h2d.Object? _lobbyPanelRoot; + private double _lobbyPanelHeight; + private UiMode _mode = UiMode.Lobby; + private bool _keepLobbyVisible; + /// Only the first Host/Join hub uses the wide centered panel + 2-column row. + private bool _hubLayout; + + // menu list rendering (absolute layout inside the styled panel) + private dc.h2d.Object? _menuRoot; + /// Plates / content card — below hover and labels. + private dc.h2d.Object? _menuChromeRoot; + /// Hover rings — above plates, below labels so text never gets covered. + private dc.h2d.Object? _menuHoverRoot; + /// Button / field labels. + private dc.h2d.Object? _menuLabelRoot; + /// Hit targets on top. + private dc.h2d.Object? _menuHitRoot; + // Custom styled panel root (replaces UIBox for hub + other menus). + private dc.h2d.Object? _panelRoot; + private readonly List<(double X, double Y, double W, double H, Action Cb)> _menuHitRects = new(); + private bool _menuVisible; + private int _layoutW = 255; + private int _layoutH = 720; + private Graphics? _hoverBorder; + private static readonly int HoverBorderColor = 0x59D5FF; + /// Same callback as the screen's Back/Disconnect button; fired on Escape. + private Action? _menuEscapeAction; private static ConnectionUI? Instance; private HSprite? spriteui; - public ConnectionUI(Process parent) : base(parent) { Instance = this; @@ -75,8 +168,6 @@ public static bool set_visible /// /// Returns the current ConnectionUI only while its Process root is still alive. - /// Returning to the main menu mid-run destroys the old TitleScreen tree first; keeping a - /// stale Instance then NRE's on visibility toggles. /// private static ConnectionUI? TryGetLiveInstance() { @@ -101,7 +192,49 @@ public static bool set_visible return instance; } - /// After gamepad connect/disconnect, window metrics can change; re-run layout to avoid blurred/scaled UI. + /// + /// Windowed mode makes the game shrink all UI (including baked text) more than fullscreen; + /// returns a multiplier that keeps menu text legible in a window. Mirrors the proven + /// GhostHero nickname-scaling logic (same display-mode detection, ~1.6x in windowed). + /// + private static double GetWindowedTextBoost() + { + try + { + var win = dc.hxd.Window.Class.getInstance(); + if (win == null) + return _cachedTextBoost; + + var displayMode = int.MinValue; + var sdlWin = win.window; + if (sdlWin != null) + displayMode = sdlWin.displayMode; + + var mode = win.fullScreenMode; + if (_cachedDisplayMode == displayMode && _cachedFullScreenMode == mode) + return _cachedTextBoost; + + _cachedDisplayMode = displayMode; + _cachedFullScreenMode = mode; + + if (displayMode == FullscreenDisplayMode || displayMode == BorderlessDisplayMode) + _cachedTextBoost = 1.0; + else if (displayMode == WindowedDisplayMode) + _cachedTextBoost = WindowedTextBoost; + else if (mode == FullscreenDisplayMode || mode == BorderlessDisplayMode) + _cachedTextBoost = 1.0; + else + _cachedTextBoost = WindowedTextBoost; + + return _cachedTextBoost; + } + catch + { + return _cachedTextBoost; + } + } + + /// After gamepad connect/disconnect, window metrics can change; re-run layout. public static void RefreshLayoutAfterDisconnect() { try @@ -115,6 +248,90 @@ public static void RefreshLayoutAfterDisconnect() } } + // ================================================================ menu screen API (called from LobbySession) + + /// Clears the pending screen, ensures the hub is visible and switches to menu mode. + public static void BeginMenu() + { + PendingButtons.Clear(); + PendingInfos.Clear(); + var instance = TryGetLiveInstance(); + if (instance != null) + { + instance._mode = UiMode.Menu; + instance._menuVisible = false; + } + set_visible = true; + } + + /// Adds a pretty button to the pending screen. + public static void AddPendingButton(string label, string help, bool enabled, int color, Action onClick, bool fieldStyle = false) + { + PendingButtons.Add(new PendingButton + { + Label = label ?? string.Empty, + Help = help ?? string.Empty, + Enabled = enabled, + Color = color, + FieldStyle = fieldStyle, + OnClick = onClick + }); + } + + /// Adds an informational line to the pending screen. + public static void AddPendingInfo(string text, int color) + { + PendingInfos.Add(new PendingInfo + { + Text = text ?? string.Empty, + Color = color + }); + } + + /// + /// Renders the accumulated pending screen. Call once at the end of a LobbySession Show* method. + /// is only for the first Host/Join screen. + /// + public static void CommitMenu(bool showLobby = false, bool hubLayout = false) + { + var instance = TryGetLiveInstance(); + if (instance == null) + return; + instance._mode = UiMode.Menu; + instance._keepLobbyVisible = showLobby; + instance._hubLayout = hubLayout && !showLobby; + // Rebuild panel at the correct width (lobby column vs hub), then draw buttons. + instance._menuVisible = true; + instance.onResize(); + } + + /// Tear down menu chrome and hide the hub (does not call TitleScreen.mainMenu). + public static void DismissAndHide() + { + var instance = TryGetLiveInstance(); + instance?.DismissMenuUi(); + set_visible = false; + } + + /// Switches to the lobby display (player list + lobby code). + public static void ShowLobbyMode() + { + var instance = TryGetLiveInstance(); + if (instance == null) + return; + instance._mode = UiMode.Lobby; + instance._menuVisible = false; + instance._keepLobbyVisible = false; + instance._hubLayout = false; + if (instance._menuRoot != null) + { + try { instance._menuRoot.visible = false; } catch { } + } + instance.onResize(); + instance.UpdateLobbyIdLabel(forceRefreshText: true); + } + + // ================================================================ screen build private void BuildUI() { @@ -125,260 +342,676 @@ private void BuildUI() this.rootFlow.set_verticalAlign(new FlowAlign.Middle()); this.rootFlow.set_horizontalAlign(new FlowAlign.Right()); - base.root.addChild(this.rootFlow); this.onResize(); - List<(string loColor, string hiColor)> colorPairs = new List<(string, string)> - { - ("#FF0000", "#0000FF"), - ("#00FF00", "#FF00FF"), - ("#FFFF00", "#FF0000"), - ("#00FFFF", "#FF00FF"), - ("#FFA500", "#800080"), - ("#FF69B4", "#4169E1"), - }; - - } - private List sprx = new List { 0.4, -1.0, -0.2, -0.6 }; - private List animlist = new List - { - "idle", "idle","idle","idle" - }; - private List sprmodu = new List + private void RebuildMenuScreen() { - "Tick4","PrisonerGold","KingWhite","PrisonerDefault" - }; - - private void loadspr(double x, string sprmuld, int count) - { - this.spritesflow = new Flow(null); - this.spritesflow.set_verticalAlign(new FlowAlign.Top()); - this.spritesflow.set_horizontalAlign(new FlowAlign.Middle()); - this.spritesflow.isVertical = false; + var uiScale = UiScale.GetResolutionScale(); + // Text must never shrink below its fullscreen size: windowed mode lowers uiScale below + // 1.0, which previously made button labels tiny and blurry while the panel stayed large. + // Windowed mode also shrinks the whole game UI on top of that, so boost text further + // there (same ~1.6x that GhostHero applies to nicknames). + var textUi = System.Math.Max(uiScale, 1.0) * GetWindowedTextBoost(); + double bgWidth = this._layoutW; + double bgHeight = this._layoutH; + // Hub layout = first Host/Join screen only. All other menus use the left column. + bool hubLayout = this._hubLayout && !this._keepLobbyVisible; + + // Rebuild the absolute-positioned menu list container. + var host = this._panelRoot; + if (host == null) + return; + this._menuRoot?.remove(); + this._menuRoot = new dc.h2d.Object(null); + host.addChild(this._menuRoot); + this._menuChromeRoot = new dc.h2d.Object(this._menuRoot); + this._menuHoverRoot = new dc.h2d.Object(this._menuRoot); + this._menuLabelRoot = new dc.h2d.Object(this._menuRoot); + this._menuHitRoot = new dc.h2d.Object(this._menuRoot); + this._menuHitRects.Clear(); + ClearHoverBorder(); + this._menuEscapeAction = null; + // Lobby-created screens (host status / client waiting): Escape must not fire Back/Disconnect. + if (!this._keepLobbyVisible) + { + for (int i = 0; i < PendingButtons.Count; i++) + { + var pending = PendingButtons[i]; + if (pending.Enabled && pending.OnClick != null && IsEscapeNavButton(pending)) + this._menuEscapeAction = pending.OnClick; + } + } + double padX = 28.0 * uiScale; + // Hub uses a centered cluster; other menus keep a left content column on the full-screen panel. + // When the lobby card is up, leave room on the right so columns don't collide. + double lobbyReserve = 0.0; + if (this._keepLobbyVisible) + lobbyReserve = GetLobbyPanelWidth(uiScale) + 24.0 * uiScale; + double listW = hubLayout + ? bgWidth - padX * 2.0 + : System.Math.Min(bgWidth - padX * 2.0 - lobbyReserve, SideContentWidth * uiScale); + double colGap = 14.0 * uiScale; + double rowGap = 16.0 * uiScale; + // Same typography boost as the hub for every styled menu screen. + double menuText = textUi * 1.55; + double cursorY; + + if (this._keepLobbyVisible) + { + EnsureLobbyPanel(); + // Actions stay left; lobby card is top-right — no longer stacked under the list. + cursorY = 22.0 * uiScale; + } + else + { + // Non-lobby screens: hide player-list chrome. + HideLobbyPanel(); + cursorY = hubLayout ? 0.0 : 22.0 * uiScale; + } + var actions = new List(); + var backs = new List(); + if (hubLayout) + { + for (int i = 0; i < PendingButtons.Count; i++) + { + var btn = PendingButtons[i]; + if (IsBackButton(btn)) + backs.Add(btn); + else + actions.Add(btn); + } + } - dc.String idle = "idle".AsHaxeString(); - string skinanim = animlist[count]; - SpriteLib g = Assets.Class.getHeroLib(Cdb.Class.getSkinInfo(sprmuld.AsHaxeString())); - this.spriteui = new HSprite(g, skinanim.AsHaxeString(), Ref.Null, null); + // Hub only: button cluster at top-center of the LARGE background panel. + double clusterW = listW; + double clusterX = padX; + if (hubLayout) + { + clusterW = System.Math.Min(listW, NavButtonClusterWidth * uiScale); + clusterX = (bgWidth - clusterW) * 0.5; + cursorY = 28.0 * uiScale; + } + // Soft content card behind the button stack so the full-screen panel feels less empty. + { + double cardPad = 18.0 * uiScale; + double contentH = EstimateMenuStackHeight(hubLayout, actions, backs, uiScale, rowGap); + double cardX = (hubLayout ? clusterX : padX) - cardPad; + double cardY = cursorY - cardPad; + double cardW = (hubLayout ? clusterW : listW) + cardPad * 2.0; + double cardH = contentH + cardPad * 2.0; + var cardGfx = new Graphics(this._menuChromeRoot ?? this._menuRoot); + UiChrome.DrawContentCard( + cardGfx, + cardX, + cardY, + cardW, + cardH, + CardCornerRadius * uiScale, + ContentCardFill, + ContentCardEdge); + } + foreach (var info in PendingInfos) + { + var line = Assets.Class.makeText( + info.Text.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#e0e0e0"), + false, + this._menuLabelRoot ?? this._menuRoot); + double infoScale = 0.42 * menuText; + line.customScale = infoScale; + line.onResize(); + line.textColor = info.Color; + if (hubLayout) + CenterMenuText(line, info.Text, clusterX, clusterW, infoScale); + else + line.x = padX; + line.y = cursorY; + cursorY += 26.0 * uiScale; + } - SpritePivot pivot = this.spriteui.pivot; - pivot.centerFactorX = x; - pivot.centerFactorY = 0.5; - pivot.usingFactor = true; - pivot.isUndefined = false; + if (PendingInfos.Count > 0) + cursorY += 10.0 * uiScale; - initColorMap(sprmuld); + if (hubLayout) + { + // Row 1: Host | Join (with tips). Row 2: Back. Top-center of big panel. + for (int i = 0; i < actions.Count; i += 2) + { + bool pair = i + 1 < actions.Count; + if (pair) + { + double btnW = (clusterW - colGap) * 0.5; + double btnH = System.Math.Max( + GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true), + GetButtonHeight(actions[i + 1], showHelp: true, uiScale, styled: true)); + PlaceMenuButton(actions[i], clusterX, cursorY, btnW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + PlaceMenuButton(actions[i + 1], clusterX + btnW + colGap, cursorY, btnW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + cursorY += btnH + rowGap; + } + else + { + double btnH = GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true); + PlaceMenuButton(actions[i], clusterX, cursorY, clusterW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + cursorY += btnH + rowGap; + } + } + for (int i = 0; i < backs.Count; i++) + { + double btnH = GetButtonHeight(backs[i], showHelp: true, uiScale, styled: true); + PlaceMenuButton(backs[i], clusterX, cursorY, clusterW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + cursorY += btnH + rowGap; + } + } + else + { + // Other menus: same new button style, stacked in the left styled panel. + for (int i = 0; i < PendingButtons.Count; i++) + { + var btn = PendingButtons[i]; + double btnH = GetButtonHeight(btn, showHelp: true, uiScale, styled: true); + PlaceMenuButton(btn, padX, cursorY, listW, btnH, menuText, uiScale, showHelp: true, centerText: false, styled: true); + cursorY += btnH + rowGap; + } + } - AnimManager animManager = this.spriteui.get_anim().play(skinanim.AsHaxeString(), null, null).loop(null); - animManager.genSpeed = 0.4; + // Hide lobby-code overlay while a menu screen is up. + if (this.lobbyCodeFlow != null) + { + try { this.lobbyCodeFlow.set_visible(false); } catch { } + } - this.spriteui.set_visible(true); - this.spritesflow.addChild(this.spriteui); - this.bg?.addChild(this.spritesflow); - this.sprites.Add(this.spriteui); + this._menuVisible = true; + this._menuRoot.set_visible(true); } - private string GetRandomAnimation(List values) + private static bool IsBackButton(PendingButton btn) { - Random fallbackRandom = new Random(); - int fallbackIndex = fallbackRandom.Next(values.Count); - return values[fallbackIndex]; + var label = btn.Label ?? string.Empty; + if (label.IndexOf("back", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + try + { + var localized = GetText.Instance.GetString("Back"); + if (!string.IsNullOrEmpty(localized) + && string.Equals(label, localized, StringComparison.OrdinalIgnoreCase)) + return true; + } + catch + { + } + return false; } - - public void playallanims(HSprite hSprite) + /// Buttons Escape should trigger (Back, or Disconnect on the client lobby screen). + private static bool IsEscapeNavButton(PendingButton btn) { + if (IsBackButton(btn)) + return true; + + var label = btn.Label ?? string.Empty; + if (label.IndexOf("disconnect", StringComparison.OrdinalIgnoreCase) >= 0) + return true; try { - var groups = hSprite.lib?.groups; - if (groups == null) - return; - - var keysIterator = groups.keys(); - animlist.Clear(); - - while (keysIterator.hasNext()) - { - string key = keysIterator.next().ToString(); - if (!key.StartsWith("Atk", StringComparison.OrdinalIgnoreCase)) - animlist.Add(key); - } + var localized = GetText.Instance.GetString("Disconnect"); + if (!string.IsNullOrEmpty(localized) + && string.Equals(label, localized, StringComparison.OrdinalIgnoreCase)) + return true; } catch { } + return false; } - - public void loadText() + private static double GetButtonHeight(PendingButton btn, bool showHelp, double uiScale, bool styled) { - + // Textblocks need a taller well once the value glyphs are larger. + if (btn.FieldStyle) + return 84.0 * uiScale; + if (showHelp && !string.IsNullOrWhiteSpace(btn.Help)) + return (styled ? 96.0 : 50.0) * uiScale; + return (styled ? 64.0 : 34.0) * uiScale; } - - - public void initColorMap(string colorMap) + /// Pre-measures the button/info stack so the content card can hug it. + private static double EstimateMenuStackHeight( + bool hubLayout, + List actions, + List backs, + double uiScale, + double rowGap) { - dc.shader.ColorMap shader = (dc.shader.ColorMap)this.spriteui!.getShader(dc.shader.ColorMap.Class); - if (shader != null) + double h = 0.0; + if (PendingInfos.Count > 0) { - this.spriteui.removeShader(shader); + h += PendingInfos.Count * 26.0 * uiScale; + h += 10.0 * uiScale; } - dc.h3d.mat.Texture texture = Res.Class.load("atlas/beheaded_aladdin_s.png".AsHaxeString()).toTexture(); - dc.h3d.mat.Filter filter = new dc.h3d.mat.Filter.Nearest(); - filter = texture.set_filter(filter); - - virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_ skinInfo = Cdb.Class.getSkinInfo(colorMap.AsHaxeString()); - dc.h3d.mat.Texture heroColorMap = Assets.Class.getHeroColorMap(skinInfo); - dc.shader.ColorMap colorMapp = (ColorMap)this.spriteui.addShader(new dc.shader.ColorMap(heroColorMap)); - - - DirLighted s2 = new DirLighted(); - s2 = (DirLighted)this.spriteui.addShader(s2); - - - dc.h3d.mat.Texture normalMapFromGroup = this.spriteui.lib.getNormalMapFromSprite(this.spriteui); - dc.shader.NormalMap normal = new dc.shader.NormalMap(normalMapFromGroup); - this.spriteui.addShader(normal); - } + if (hubLayout) + { + for (int i = 0; i < actions.Count; i += 2) + { + bool pair = i + 1 < actions.Count; + double btnH = pair + ? System.Math.Max( + GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true), + GetButtonHeight(actions[i + 1], showHelp: true, uiScale, styled: true)) + : GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true); + h += btnH + rowGap; + } + for (int i = 0; i < backs.Count; i++) + h += GetButtonHeight(backs[i], showHelp: true, uiScale, styled: true) + rowGap; + } + else + { + for (int i = 0; i < PendingButtons.Count; i++) + h += GetButtonHeight(PendingButtons[i], showHelp: true, uiScale, styled: true) + rowGap; + } - private void clean() - { - ClearLobbyCodeUi(); - this.bg?.remove(); - this.rootFlow?.remove(); - this.inter?.remove(); - this.sprites.Clear(); + // Drop the trailing gap; keep a little breathing room for the soft shadow. + if (h > rowGap) + h -= rowGap; + h += 6.0 * uiScale; + return System.Math.Max(h, 80.0 * uiScale); } - - public override void onResize() + private void PlaceMenuButton( + PendingButton btn, + double x, + double y, + double w, + double h, + double textUi, + double uiScale, + bool showHelp, + bool centerText, + bool styled) { - base.onResize(); - if (this.rootFlow == null || base.root == null) + if (btn.FieldStyle) + { + PlaceMenuTextBlock(btn, x, y, w, h, textUi, uiScale); return; + } - var win = dc.hxd.Window.Class.getInstance(); - double screenWidth = win.get_width(); - double screenHeight = win.get_height(); - var uiScale = UiScale.GetResolutionScale(); + DrawButtonPlate(btn, x, y, w, h, uiScale); + double labelScale = (styled ? 0.72 : 0.48) * textUi; + var label = Assets.Class.makeText( + btn.Label.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#ffffff"), + false, + this._menuLabelRoot ?? this._menuRoot); + label.customScale = labelScale; + label.onResize(); + label.textColor = btn.Enabled ? (btn.Color == 0xFFFFFF ? TextColor : btn.Color) : DisabledColor; - this.rootFlow.set_minWidth((int)(screenWidth * 0.4)); //宽度 40% - this.rootFlow.set_minHeight((int)(screenHeight * 0.3)); // 高度 30% - this.rootFlow.reflow(); + bool hasHelp = showHelp && !string.IsNullOrWhiteSpace(btn.Help); + if (centerText) + { + CenterMenuText(label, btn.Label, x, w, labelScale); + label.y = hasHelp ? y + (styled ? 16.0 : 8.0) * uiScale : y + (h - 18.0 * uiScale) * 0.5; + } + else + { + label.x = x + 14.0 * uiScale; + label.y = hasHelp ? y + 14.0 * uiScale : y + 16.0 * uiScale; + } + if (hasHelp) + { + double helpScale = (styled ? 0.60 : 0.40) * textUi; + var help = Assets.Class.makeText( + btn.Help.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#9098a8"), + false, + this._menuLabelRoot ?? this._menuRoot); + help.customScale = helpScale; + help.onResize(); + help.textColor = btn.Enabled ? HelpColor : DisabledHelpColor; + if (centerText) + { + CenterMenuText(help, btn.Help, x, w, helpScale); + help.y = y + h - (styled ? 36.0 : 22.0) * uiScale; + } + else + { + help.x = x + 14.0 * uiScale; + help.y = y + h - 34.0 * uiScale; + } + } - double flowW = this.rootFlow.get_innerWidth(); - double flowH = this.rootFlow.get_innerHeight(); + AttachMenuHit(btn, x, y, w, h, fieldHover: false); + } - ClearLobbyCodeUi(); - this.bg?.remove(); - this.bg = UIBox.Class.drawBoxValidation( - (int)flowW, - (int)flowH, - Ref.Null, - Ref.Null, - null, - false - ); - this.root.addChild(this.bg); + /// + /// Form-style textblock: muted caption above + recessed value well. Must not read as a button. + /// + private void PlaceMenuTextBlock( + PendingButton btn, + double x, + double y, + double w, + double h, + double textUi, + double uiScale) + { + SplitFieldCaption(btn, out var caption, out var value); - this.bg.set_visible(true); - this.bg.wid = (int)255; - this.bg.hei = (int)flowH; + double captionScale = 0.48 * textUi; + var captionText = Assets.Class.makeText( + caption.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#8a93a6"), + false, + this._menuLabelRoot ?? this._menuRoot); + captionText.customScale = captionScale; + captionText.onResize(); + captionText.textColor = btn.Enabled ? 0x8A93A6 : DisabledHelpColor; + captionText.x = x + 2.0 * uiScale; + captionText.y = y; + + double wellY = y + 28.0 * uiScale; + double wellH = System.Math.Max(44.0 * uiScale, h - 32.0 * uiScale); + DrawFieldBox(btn, x, wellY, w, wellH); + + double valueScale = 0.78 * textUi; + var valueText = Assets.Class.makeText( + value.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#e8eef7"), + false, + this._menuLabelRoot ?? this._menuRoot); + valueText.customScale = valueScale; + valueText.onResize(); + valueText.textColor = btn.Enabled ? 0xE8EEF7 : DisabledColor; + valueText.x = x + 14.0 * uiScale; + // DC bitmap fonts report a tall box with empty ascent; visible pixels hug the + // bottom of that box, so a naive mid-well Y leaves glyphs on the floor. + valueText.y = GetFieldGlyphY(wellY, wellH, valueText, valueScale); + + // Quiet edit affordance — not a second button label. + var editHint = Assets.Class.makeText( + "›".AsHaxeString(), + Tools.MultiColor.ColorFromHex("#59d5ff"), + false, + this._menuLabelRoot ?? this._menuRoot); + double hintScale = 0.72 * textUi; + editHint.customScale = hintScale; + editHint.onResize(); + editHint.textColor = AccentColor; + double hintW = 10.0 * uiScale; + try + { + hintW = editHint.textWidth; + if (editHint.scaleX > 0.01) + hintW *= editHint.scaleX; + else + hintW *= hintScale; + } + catch { } + editHint.x = x + w - hintW - 14.0 * uiScale; + editHint.y = GetFieldGlyphY(wellY, wellH, editHint, hintScale); + AttachMenuHit(btn, x, wellY, w, wellH, fieldHover: true); + } - double posX = screenWidth - flowW - base.get_pixelScale.Invoke() * 200.0; // 离右边 20 像素 - posX = screenWidth - flowW - base.get_pixelScale.Invoke() * 200.0 * uiScale; - double posY = (screenHeight - flowH) / 1.35; - this.rootFlow.x = posX; - this.rootFlow.y = posY; + /// + /// Y so the *visible* glyph band sits mid-well. Dead Cells ui.Text boxes are taller + /// than the ink; centering the raw box parks the ink on the bottom edge. + /// + private static double GetFieldGlyphY(double wellY, double wellH, dc.ui.Text text, double scale) + { + double boxH = 20.0 * System.Math.Max(scale, 0.4); + try + { + boxH = text.textHeight; + if (text.scaleY > 0.01) + boxH *= text.scaleY; + else + boxH *= scale; + } + catch + { + } + if (boxH < 8.0) + boxH = 20.0 * System.Math.Max(scale, 0.4); - this.bg.x = posX; - this.bg.y = posY; + // Empty ascent ≈ top 38% of the reported box for this font atlas. + // (Was 0.45 — sat a bit above optical center.) + const double emptyAscentFrac = 0.38; + double inkTopInBox = boxH * emptyAscentFrac; + double inkH = boxH * (1.0 - emptyAscentFrac); + // Place ink band in the vertical center of the well. + return wellY + (wellH - inkH) * 0.5 - inkTopInBox; + } + private static void SplitFieldCaption(PendingButton btn, out string caption, out string value) + { + string raw = btn.Label ?? string.Empty; + int idx = raw.IndexOf(':'); + if (idx > 0) + { + caption = raw.Substring(0, idx).Trim(); + value = raw.Substring(idx + 1).Trim(); + if (string.IsNullOrEmpty(value)) + value = "—"; + return; + } - this.inter?.remove(); - this.inter = new dc.h2d.Interactive(this.bg.wid, this.bg.hei, this.bg, null); - this.inter.onClick = new HlAction(this.OnClick); - BGtext(); - UpdateLobbyIdLabel(forceRefreshText: true); + caption = !string.IsNullOrWhiteSpace(btn.Help) ? btn.Help.Trim() : "Value"; + value = string.IsNullOrWhiteSpace(raw) ? "—" : raw.Trim(); } + private void AttachMenuHit(PendingButton btn, double x, double y, double w, double h, bool fieldHover) + { + if (!btn.Enabled || btn.OnClick == null || this._menuRoot == null) + return; - private void BGtext() + var hitParent = this._menuHitRoot ?? this._menuRoot; + var hit = new dc.h2d.Interactive(w, h, hitParent, null); + hit.x = x; + hit.y = y; + var cb = btn.OnClick; + double hx = x, hy = y, hw = w, hh = h; + hit.onOver = new HlAction(_ => + { + if (fieldHover) + SetFieldHoverBorder(hx, hy, hw, hh); + else + SetHoverBorder(hx, hy, hw, hh); + }); + hit.onOut = new HlAction(_ => ClearHoverBorder()); + hit.onClick = new HlAction(_ => + { + ClearHoverBorder(); + try { cb(); } + catch (Exception ex) { Log.Debug("[ConnectionUI] Button callback failed: {Message}", ex.Message); } + }); + this._menuHitRects.Add((x, y, w, h, cb)); + } + + private void SetHoverBorder(double x, double y, double w, double h) { - this.MainTitleflow = new Flow(null); - this.MainTitleflow.isVertical = true; - var uiScale = UiScale.GetResolutionScale(); + ClearHoverBorder(); + var parent = this._menuHoverRoot ?? this._menuRoot; + if (parent == null) + return; + var g = new Graphics(parent); + this._hoverBorder = g; + double uiScale = UiScale.GetResolutionScale(); + UiChrome.DrawHoverRing( + g, + x, + y, + w, + h, + ButtonCornerRadius * uiScale, + HoverBorderColor, + PanelInner); + } - FlowAlign flowAlign = this.MainTitleflow.set_horizontalAlign(new FlowAlign.Middle()); - flowAlign = this.MainTitleflow.set_verticalAlign(new FlowAlign.Top()); + /// Same cyan hover language as buttons, punched with the field face color. + private void SetFieldHoverBorder(double x, double y, double w, double h) + { + ClearHoverBorder(); + var parent = this._menuHoverRoot ?? this._menuRoot; + if (parent == null) + return; + var g = new Graphics(parent); + this._hoverBorder = g; + double uiScale = UiScale.GetResolutionScale(); + UiChrome.DrawHoverRing( + g, + x, + y, + w, + h, + FieldCornerRadius * uiScale, + HoverBorderColor, + FieldFace); + } - double bgWidth = this.bg!.wid; - double bgHeight = this.bg.hei; - this.MainTitleflow.set_minWidth((int)bgWidth); - this.MainTitleflow.set_minHeight((int)bgHeight); + private void ClearHoverBorder() + { + try { this._hoverBorder?.remove(); } catch { } + this._hoverBorder = null; + } + private void DismissMenuUi() + { + this._menuVisible = false; + this._hubLayout = false; + this._mode = UiMode.Lobby; + this._menuEscapeAction = null; + ClearHoverBorder(); + CloseTextPrompt(apply: false); + this.inter?.remove(); + this.inter = null; + try { this._menuRoot?.remove(); } catch { } + this._menuRoot = null; + this._menuChromeRoot = null; + this._menuHoverRoot = null; + this._menuLabelRoot = null; + this._menuHitRoot = null; + ClearLobbyPanel(); + try { this._panelRoot?.remove(); } catch { } + this._panelRoot = null; + this._menuHitRects.Clear(); + } - this.bg!.addChild(this.MainTitleflow); - dc.ui.Text title = Assets.Class.makeText( - GetText.Instance.GetString("Lobby menu").AsHaxeString(), - Tools.MultiColor.ColorFromHex("#f7fc65"), - true, - null - ); + private static void CenterMenuText(dc.ui.Text text, string label, double regionX, double regionW, double customScale) + { + double textWidth; + try + { + textWidth = text.textWidth; + if (text.scaleX > 0.01) + textWidth *= text.scaleX; + else if (customScale > 0.01) + textWidth *= customScale; + } + catch + { + textWidth = label.Length * 7.0 * System.Math.Max(customScale, 0.4); + } - title.scaleX = 0.6 * uiScale; - title.scaleY = 0.6 * uiScale; + if (textWidth <= 0) + textWidth = label.Length * 7.0 * System.Math.Max(customScale, 0.4); - this.MainTitleflow.addChild(title); + text.x = System.Math.Max(regionX + 4.0, regionX + (regionW - textWidth) * 0.5); + } + /// + /// Recessed textblock well: soft shadow + rounded border + dug-in face. + /// + private void DrawFieldBox(PendingButton btn, double x, double y, double w, double h) + { + var parent = this._menuChromeRoot ?? this._menuRoot; + if (parent == null) + return; + var g = new Graphics(parent); + double uiScale = UiScale.GetResolutionScale(); + UiChrome.DrawInsetWell( + g, + x, + y, + w, + h, + FieldCornerRadius * uiScale, + btn.Enabled ? FieldBorder : DisabledPlateEdge, + btn.Enabled ? FieldFace : DisabledPlateFace, + btn.Enabled); + } - Flow titleWrapper = new Flow(null); - titleWrapper.isVertical = false; - titleWrapper.set_horizontalAlign(new FlowAlign.Middle()); + private void DrawButtonPlate(PendingButton btn, double x, double y, double w, double h, double uiScale) + { + var parent = this._menuChromeRoot ?? this._menuRoot; + if (parent == null) + return; + var g = new Graphics(parent); + UiChrome.DrawRaisedPlate( + g, + x, + y, + w, + h, + ButtonCornerRadius * uiScale, + btn.Enabled ? PanelInnerEdge : DisabledPlateEdge, + btn.Enabled ? PanelInner : DisabledPlateFace, + btn.Enabled ? PanelInnerTop : DisabledPlateTop, + btn.Enabled); + } - titleWrapper.addChild(title); - this.MainTitleflow.addChild(titleWrapper); + // ================================================================ lobby display - dc.ui.Text subtitle = Assets.Class.makeText( - GetText.Instance.GetString("Players' list").AsHaxeString(), - Tools.MultiColor.ColorFromHex("#919191"), - false, - null - ); - subtitle.scaleX = 0.5 * uiScale; - subtitle.scaleY = 0.5 * uiScale; + private const double LobbyPanelBaseWidth = 440.0; + private static double GetLobbyPanelWidth(double uiScale) + { + return LobbyPanelBaseWidth * uiScale; + } - Flow subtitleWrapper = new Flow(null); - subtitleWrapper.isVertical = false; - subtitleWrapper.set_horizontalAlign(new FlowAlign.Middle()); + private void EnsureLobbyPanel() + { + if (this._panelRoot == null) + return; - subtitleWrapper.addChild(subtitle); - this.MainTitleflow.addChild(subtitleWrapper); + if (this._lobbyPanelRoot == null) + { + this._lobbyPanelRoot = new dc.h2d.Object(null); + this._panelRoot.addChild(this._lobbyPanelRoot); + } - Flow playersListWrapper = new Flow(null); - playersListWrapper.isVertical = true; - playersListWrapper.set_horizontalAlign(new FlowAlign.Middle()); - playersListWrapper.set_verticalSpacing((int)(4 * uiScale)); + try { this._lobbyPanelRoot.set_visible(true); } catch { } + RebuildLobbyPanelContent(_ConnectionUI.GetLobbyPlayerSlots()); + } - this.MainTitleflow.addChild(playersListWrapper); - updateConnections(); - this.MainTitleflow.reflow(); + private void HideLobbyPanel() + { + if (this._lobbyPanelRoot != null) + { + try { this._lobbyPanelRoot.set_visible(false); } catch { } + } + HideLobbyBeheadedSprites(); + } + private void ClearLobbyPanel() + { + ClearLobbyBeheadedSprites(); + try { this._lobbyPanelRoot?.remove(); } catch { } + this._lobbyPanelRoot = null; + this.connectionLabels.Clear(); + this.lastConnections.Clear(); + this.lastLobbySlotsSignature = string.Empty; } public void updateConnections() @@ -393,48 +1026,126 @@ public static void NotifyConnectionsChanged() private void RefreshConnections(List? names) { - if (this.MainTitleflow == null) + if (!(this._mode == UiMode.Lobby || this._keepLobbyVisible)) + return; + + if (this._lobbyPanelRoot == null) + { + if (this._panelRoot == null) + return; + EnsureLobbyPanel(); + return; + } + + var slots = _ConnectionUI.GetLobbyPlayerSlots(); + if (!NeedsLobbySlotsRefresh(slots)) + return; + RebuildLobbyPanelContent(slots); + } + + private bool NeedsLobbySlotsRefresh(List<_ConnectionUI.LobbyPlayerSlot> slots) + { + var next = _ConnectionUI.BuildLobbySlotsSignature(slots); + return !string.Equals(this.lastLobbySlotsSignature, next, StringComparison.Ordinal); + } + + private void RebuildLobbyPanelContent(List<_ConnectionUI.LobbyPlayerSlot> slots) + { + if (this._lobbyPanelRoot == null || this._panelRoot == null) return; var uiScale = UiScale.GetResolutionScale(); - for (int i = 0; i < this.connectionLabels.Count; i++) + var textBoost = GetWindowedTextBoost(); + double textUi = System.Math.Max(uiScale, 1.0) * textBoost * 1.35; + double panelW = GetLobbyPanelWidth(uiScale); + double pad = 20.0 * uiScale; + double screenPad = 28.0 * uiScale; + + string? lobbyCode = null; + try + { + lobbyCode = LobbySession.GetSteamLobbyCodeForUi(); + if (string.IsNullOrWhiteSpace(lobbyCode)) + lobbyCode = null; + else + lobbyCode = lobbyCode.Trim().ToLowerInvariant(); + } + catch { - var label = this.connectionLabels[i]; - this.MainTitleflow.removeChild(label); - label.remove(); + lobbyCode = null; } + + this._lobbyPanelRoot.removeChildren(); this.connectionLabels.Clear(); - List allname = names ?? _ConnectionUI.GetAllPlayerNames(); - foreach (var name in allname) - { - bool isSteamLobbyConnecting = string.Equals(name, _ConnectionUI.SteamLobbyConnectingMarker, StringComparison.Ordinal); - bool isConnecting = - isSteamLobbyConnecting - || string.Equals(name, "connecting", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "connecting...", StringComparison.OrdinalIgnoreCase); - string displayName = isSteamLobbyConnecting - ? GetText.Instance.GetString("Connecting to Steam lobby...") - : isConnecting - ? GetText.Instance.GetString("connecting...") - : $"{GetText.Instance.GetString("- ")}{name}"; - var nameColor = Tools.MultiColor.ColorFromHex("#c9c9c9"); - dc.ui.Text player2 = Assets.Class.makeText( - displayName.AsHaxeString(), - nameColor, - false, - null - ); - player2.customScale = 0.5 * uiScale; - player2.onResize(); - player2.textColor = nameColor; - this.MainTitleflow.addChild(player2); - this.connectionLabels.Add(player2); + // Beheaded row IS the players list — only keep a small card when a Steam lobby code is shown. + if (lobbyCode != null) + { + double codeBlockH = 70.0 * uiScale; + double panelH = pad + codeBlockH + pad; + + var chrome = new Graphics(this._lobbyPanelRoot); + UiChrome.DrawContentCard( + chrome, + 0, + 0, + panelW, + panelH, + CardCornerRadius * uiScale, + ContentCardFill, + ContentCardEdge); + + var codeCaption = Assets.Class.makeText( + GetText.Instance.GetString("Lobby code").AsHaxeString(), + Tools.MultiColor.ColorFromHex("#8a93a6"), + false, + this._lobbyPanelRoot); + codeCaption.customScale = 0.42 * textUi; + codeCaption.onResize(); + codeCaption.textColor = 0x8A93A6; + codeCaption.x = pad; + codeCaption.y = pad; + + var codeValue = Assets.Class.makeText( + lobbyCode.AsHaxeString(), + Tools.MultiColor.ColorFromHex("#59d5ff"), + false, + this._lobbyPanelRoot); + codeValue.customScale = 0.62 * textUi; + codeValue.onResize(); + codeValue.textColor = AccentColor; + codeValue.x = pad; + codeValue.y = pad + 26.0 * uiScale; + + this.lastLobbyIdLabelText = lobbyCode; + this._lobbyPanelHeight = panelH; + this._lobbyPanelRoot.x = this._layoutW - panelW - screenPad; + this._lobbyPanelRoot.y = screenPad; + try { this._lobbyPanelRoot.set_visible(true); } catch { } + } + else + { + this.lastLobbyIdLabelText = string.Empty; + this._lobbyPanelHeight = 0; + this._lobbyPanelRoot.x = this._layoutW - panelW - screenPad; + this._lobbyPanelRoot.y = screenPad; + try { this._lobbyPanelRoot.set_visible(false); } catch { } } this.lastConnections.Clear(); - this.lastConnections.AddRange(allname); - UpdateLobbyIdLabel(forceRefreshText: false); + for (int i = 0; i < slots.Count; i++) + { + if (slots[i].Occupied) + this.lastConnections.Add(slots[i].Nick); + } + this.lastLobbySlotsSignature = _ConnectionUI.BuildLobbySlotsSignature(slots); + + PlaceLobbyBeheadedUnderPlayerList(slots, panelW, uiScale, textUi, screenPad); + + if (this.lobbyCodeFlow != null) + { + try { this.lobbyCodeFlow.set_visible(false); } catch { } + } } private void ClearLobbyCodeUi() @@ -449,161 +1160,258 @@ private void ClearLobbyCodeUi() this.lastLobbyIdLabelText = string.Empty; } - private void EnsureLobbyCodeFlow(double uiScale) + private void UpdateLobbyIdLabel(bool forceRefreshText) { - if (this.bg == null || base.root == null) - return; - - if (this.lobbyCodeFlow == null) + if (!(this._mode == UiMode.Lobby || this._keepLobbyVisible)) { - this.lobbyCodeFlow = new Flow(null); - this.lobbyCodeFlow.isVertical = true; - this.lobbyCodeFlow.set_horizontalAlign(new FlowAlign.Left()); - this.lobbyCodeFlow.set_verticalAlign(new FlowAlign.Bottom()); - this.lobbyCodeFlow.set_verticalSpacing((int)(2 * uiScale)); - this.lobbyCodeFlow.x += 10; - this.lobbyCodeFlow.y += 80; - this.bg.addChild(this.lobbyCodeFlow); + if (this.lobbyCodeFlow != null) + { + try { this.lobbyCodeFlow.set_visible(false); } catch { } + } + return; } - if (this.lobbyCodeTitleLabel == null) + if (this._lobbyPanelRoot == null) + return; + + string? lobbyCode = null; + try { - var titleColor = Tools.MultiColor.ColorFromHex("#9ea8b3"); - this.lobbyCodeTitleLabel = Assets.Class.makeText( - GetText.Instance.GetString("Lobby code").AsHaxeString(), - titleColor, - false, - null); - this.lobbyCodeFlow.addChild(this.lobbyCodeTitleLabel); - this.lobbyCodeTitleLabel.textColor = titleColor; + lobbyCode = LobbySession.GetSteamLobbyCodeForUi(); + if (string.IsNullOrWhiteSpace(lobbyCode)) + lobbyCode = null; + else + lobbyCode = lobbyCode.Trim().ToLowerInvariant(); } - - if (this.lobbyIdLabel == null) + catch { - var idColor = Tools.MultiColor.ColorFromHex("#7fd4ff"); - this.lobbyIdLabel = Assets.Class.makeText( - string.Empty.AsHaxeString(), - idColor, - true, - null); - this.lobbyCodeFlow.addChild(this.lobbyIdLabel); - this.lobbyIdLabel.textColor = idColor; + lobbyCode = null; } - var lobbyCodeScale = 0.55 * uiScale; - this.lobbyCodeTitleLabel.customScale = lobbyCodeScale; - this.lobbyCodeTitleLabel.onResize(); - this.lobbyCodeTitleLabel.textColor = Tools.MultiColor.ColorFromHex("#9ea8b3"); - this.lobbyIdLabel.customScale = lobbyCodeScale; - this.lobbyIdLabel.onResize(); - this.lobbyIdLabel.textColor = Tools.MultiColor.ColorFromHex("#7fd4ff"); + var next = lobbyCode ?? string.Empty; + if (!forceRefreshText && string.Equals(this.lastLobbyIdLabelText, next, StringComparison.Ordinal)) + return; + + // Code appeared/changed — rebuild the card so the footer updates. + RebuildLobbyPanelContent(_ConnectionUI.GetLobbyPlayerSlots()); } - private void UpdateLobbyIdLabel(bool forceRefreshText) + // ================================================================ lifecycle + + private void clean() { - if (this.bg == null) - return; + ClearLobbyCodeUi(); + ClearLobbyPanel(); + ClearHoverBorder(); + CloseTextPrompt(apply: false); + this._menuRoot?.remove(); + this._menuRoot = null; + this._menuChromeRoot = null; + this._menuHoverRoot = null; + this._menuLabelRoot = null; + this._menuHitRoot = null; + this._panelRoot?.remove(); + this._panelRoot = null; + this.rootFlow?.remove(); + this.inter?.remove(); + this.sprites.Clear(); + } - var lobbyCode = GameMenu.GetSteamLobbyCodeForUi(); - if (string.IsNullOrWhiteSpace(lobbyCode)) - { - if (this.lobbyCodeFlow != null) - this.lobbyCodeFlow.set_visible(false); - this.lastLobbyIdLabelText = string.Empty; - return; - } + // Button column width inside the full-screen panel (non-hub menus). + private const int SideContentWidth = 560; + private const double NavButtonClusterWidth = 780.0; - var uiScale = UiScale.GetResolutionScale(); - var labelText = lobbyCode.Trim().ToLowerInvariant(); - EnsureLobbyCodeFlow(uiScale); - if (this.lobbyCodeFlow == null || this.lobbyIdLabel == null || this.lobbyCodeTitleLabel == null) + public override void onResize() + { + base.onResize(); + if (this.rootFlow == null || base.root == null) return; - if (forceRefreshText || !string.Equals(this.lastLobbyIdLabelText, labelText, StringComparison.Ordinal)) + var win = dc.hxd.Window.Class.getInstance(); + double screenWidth = win.get_width(); + double screenHeight = win.get_height(); + + ClearLobbyCodeUi(); + this.inter?.remove(); + this.inter = null; + + if (this.rootFlow != null) { - this.lobbyIdLabel.set_text(labelText.AsHaxeString()); - this.lastLobbyIdLabelText = labelText; + try { this.rootFlow.visible = false; } catch { } } - var leftPadding = 10.0 * uiScale; - var bottomPadding = 8.0 * uiScale; - this.lobbyCodeFlow.reflow(); - var flowHeight = this.lobbyCodeFlow.get_innerHeight(); - this.lobbyCodeFlow.x = this.bg.x + leftPadding; - this.lobbyCodeFlow.y = this.bg.y + this.bg.hei - flowHeight - bottomPadding; - this.lobbyCodeFlow.set_visible(true); + // Panel is always absolute full screen — never a short/cropped box. + BuildFullScreenPanel(screenWidth, screenHeight); + + bool showLobbyCard = this._mode == UiMode.Lobby || this._keepLobbyVisible; + if (showLobbyCard) + EnsureLobbyPanel(); + else + HideLobbyPanel(); + + if (this._mode == UiMode.Menu && this._menuVisible) + RebuildMenuScreen(); + + UpdateLobbyIdLabel(forceRefreshText: true); + + // Display-mode / resolution changes rebuild the panel on top of an open prompt. + // Re-draw the prompt last so it stays above and matches the new size. + if (this._promptOpen) + RebuildTextPromptUi(); } - private bool NeedsConnectionsRefresh(List names) + private void DrawStyledPanelFrame(dc.h2d.Object parent, double panelW, double panelH) { - if (names.Count != this.lastConnections.Count) - return true; + var g = new Graphics(parent); + int fill = PanelInner; + double fillAlpha = 0.94; + g.beginFill(Ref.From(ref fill), Ref.From(ref fillAlpha)); + g.drawRect(0, 0, panelW, panelH); + g.endFill(); + + // Soft vignette corners so the full-bleed panel feels less flat. + int vignette = 0x000000; + double vA = 0.18; + g.beginFill(Ref.From(ref vignette), Ref.From(ref vA)); + g.drawRect(0, 0, panelW, 18); + g.drawRect(0, panelH - 28, panelW, 28); + g.endFill(); + + int edge = AccentColor; + double edgeAlpha = 0.45; + g.beginFill(Ref.From(ref edge), Ref.From(ref edgeAlpha)); + g.drawRect(0, 0, panelW, 2); + g.drawRect(0, panelH - 2, panelW, 2); + g.drawRect(0, 0, 2, panelH); + g.drawRect(panelW - 2, 0, 2, panelH); + g.endFill(); + } - for (int i = 0; i < names.Count; i++) - { - if (!string.Equals(names[i], this.lastConnections[i], StringComparison.Ordinal)) - return true; - } + /// Full-screen styled backdrop for every ConnectionUI menu. + private void BuildFullScreenPanel(double screenWidth, double screenHeight) + { + this._panelRoot?.remove(); + this._panelRoot = new dc.h2d.Object(null); + this.root.addChild(this._panelRoot); - return false; + int panelW = System.Math.Max(1, (int)screenWidth); + int panelH = System.Math.Max(1, (int)screenHeight); + + this._layoutW = panelW; + this._layoutH = panelH; + DrawStyledPanelFrame(this._panelRoot, panelW, panelH); + + // Full panel rebuild invalidates any previous lobby card reference. + this._lobbyPanelRoot = null; + + this._panelRoot.x = 0; + this._panelRoot.y = 0; + this._panelRoot.set_visible(true); + + this.inter = new dc.h2d.Interactive(panelW, panelH, this._panelRoot, null); + this.inter.onClick = new HlAction(this.OnClick); } + private void BGtext() + { + // Legacy entry point — lobby chrome now lives in EnsureLobbyPanel / RebuildLobbyPanelContent. + EnsureLobbyPanel(); + } public override void update() { base.update(); - var names = _ConnectionUI.GetAllPlayerNames(); - if (NeedsConnectionsRefresh(names)) - RefreshConnections(names); - else - UpdateLobbyIdLabel(forceRefreshText: false); + bool promptWasOpen = this._promptOpen; + TickTextPrompt(); + TickMenuEscape(promptWasOpen); - if (dc.hxd.Key.Class.isPressed(80)) + if (this._mode != UiMode.Menu || this._keepLobbyVisible) { - clean(); - Log.Debug("destory ui"); - - + var slots = _ConnectionUI.GetLobbyPlayerSlots(); + if (NeedsLobbySlotsRefresh(slots)) + RebuildLobbyPanelContent(slots); + else + UpdateLobbyIdLabel(forceRefreshText: false); + } + else if (this._menuVisible) + { + UpdateLobbyIdLabel(forceRefreshText: false); } } - public override void postUpdate() + /// Escape = same as Back/Disconnect, unless the text prompt consumed Escape this frame. + private void TickMenuEscape(bool promptWasOpen) { - base.postUpdate(); + if (promptWasOpen || this._promptOpen) + return; + // Host/client lobby (session already created): never treat Escape as Back/Disconnect. + if (this._keepLobbyVisible) + return; + if (!this._menuVisible || this._menuEscapeAction == null) + return; + if (!set_visible) + return; + try + { + if (!Key.Class.isPressed(27)) + return; + var cb = this._menuEscapeAction; + try { cb(); } + catch (Exception ex) { Log.Debug("[ConnectionUI] Escape back failed: {Message}", ex.Message); } + } + catch + { + } } private void OnClick(Event e) { - if (this.lobbyCodeFlow == null || !this.lobbyCodeFlow.visible || this.bg == null) - return; + // Menu buttons (hit rects relative to the styled panel Interactive). + if (this._menuVisible && this._menuRoot != null && this._menuRoot.visible) + { + var x = e.relX; + var y = e.relY; + for (int i = 0; i < this._menuHitRects.Count; i++) + { + var r = this._menuHitRects[i]; + if (x >= r.X && x <= r.X + r.W && y >= r.Y && y <= r.Y + r.H) + { + try { r.Cb(); } + catch (Exception ex) { Log.Debug("[ConnectionUI] Button callback failed: {Message}", ex.Message); } + return; + } + } + } - var x = e.relX; - var y = e.relY; - var width = this.lobbyCodeFlow.get_innerWidth(); - var height = this.lobbyCodeFlow.get_innerHeight(); - var minX = this.lobbyCodeFlow.x - this.bg.x; - var minY = this.lobbyCodeFlow.y - this.bg.y; - var maxX = minX + width; - var maxY = minY + height; + // Lobby card click → copy Steam lobby code when present. + if (this._lobbyPanelRoot == null || !this._lobbyPanelRoot.visible) + return; + if (string.IsNullOrEmpty(this.lastLobbyIdLabelText)) + return; - if (x < minX || x > maxX || y < minY || y > maxY) + var relX = e.relX; + var relY = e.relY; + double cardX = this._lobbyPanelRoot.x; + double cardY = this._lobbyPanelRoot.y; + double cardW = GetLobbyPanelWidth(UiScale.GetResolutionScale()); + double cardH = this._lobbyPanelHeight > 1.0 + ? this._lobbyPanelHeight + : 420.0 * UiScale.GetResolutionScale(); + if (relX < cardX || relX > cardX + cardW || relY < cardY || relY > cardY + cardH) return; - if (GameMenu.TryCopySteamLobbyCodeFromUi()) + if (LobbySession.TryCopySteamLobbyCodeFromUi()) MultiplayerUI.PushSystemMessage("Lobby id copied to clipboard"); - } - public static void Initialize(ModEntry entry) { entry.Logger.Information("\x1b[32m[[ModEntry.ConnectionUI] Initializing ConnectionUI...]\x1b[0m "); } /// - /// Ensures ConnectionUI exists on the given TitleScreen. Called from mainMenu hook - /// to avoid Hashlink marshaling crash in TitleScreen constructor (bool? titleLib). + /// Ensures ConnectionUI exists on the given TitleScreen. Called from mainMenu hook. /// public static void EnsureCreated(TitleScreen screen) { @@ -622,8 +1430,5 @@ public static void EnsureCreated(TitleScreen screen) { } } - - - } } diff --git a/UI/ConnectionUI/CoopIdentity.Api.cs b/UI/ConnectionUI/CoopIdentity.Api.cs new file mode 100644 index 0000000..a1c2f70 --- /dev/null +++ b/UI/ConnectionUI/CoopIdentity.Api.cs @@ -0,0 +1,8 @@ +namespace DeadCellsMultiplayerMod; + +/// Co-op identity API surface (implementation lives in LobbySession CoopIdentity partial). +internal static class CoopIdentity +{ + public static void ReceiveRemoteCoopState(int userId, string? coopId, bool hasContinueSave) + => LobbySession.ReceiveRemoteCoopState(userId, coopId, hasContinueSave); +} diff --git a/UI/GameMenu.CoopIdentity.cs b/UI/ConnectionUI/CoopIdentity.cs similarity index 84% rename from UI/GameMenu.CoopIdentity.cs rename to UI/ConnectionUI/CoopIdentity.cs index e7d8df2..969bbf3 100644 --- a/UI/GameMenu.CoopIdentity.cs +++ b/UI/ConnectionUI/CoopIdentity.cs @@ -3,22 +3,22 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { - private const string ContinueReasonOk = "OK"; - private static readonly Dictionary _remoteCoopStates = new(); - private static bool _receivedLaunchPayload; - private static bool _receivedNewCoopWorldPrepared; - private static bool _pendingNewCoopWorldIdAssigned; - private static string? _storedPendingNewCoopWorldCoopId; - private static int? _storedPendingNewCoopWorldSeed; - private static int _continueSaveCacheSlot = -1; - private static long _continueSaveCacheTicks; - private static bool _continueSaveCacheValid; - private static bool _continueSaveCacheHasSave; - private static string _continueSaveCacheReason = ContinueReasonOk; - private static string _lastLoggedClientContinueBlockReason = string.Empty; - private const double ContinueSaveCacheSeconds = 1.0; + internal const string ContinueReasonOk = "OK"; + internal static readonly Dictionary _remoteCoopStates = new(); + internal static bool _receivedLaunchPayload; + internal static bool _receivedNewCoopWorldPrepared; + internal static bool _pendingNewCoopWorldIdAssigned; + internal static string? _storedPendingNewCoopWorldCoopId; + internal static int? _storedPendingNewCoopWorldSeed; + internal static int _continueSaveCacheSlot = -1; + internal static long _continueSaveCacheTicks; + internal static bool _continueSaveCacheValid; + internal static bool _continueSaveCacheHasSave; + internal static string _continueSaveCacheReason = ContinueReasonOk; + internal static string _lastLoggedClientContinueBlockReason = string.Empty; + internal const double ContinueSaveCacheSeconds = 1.0; public static void ReceiveRemoteCoopState(int userId, string? coopId, bool hasContinueSave) { @@ -35,7 +35,7 @@ public static void ReceiveRemoteCoopState(int userId, string? coopId, bool hasCo RequestLobbyMenuRefresh(); } - private static void ResetRemoteCoopStateLocked() + internal static void ResetRemoteCoopStateLocked() { _remoteCoopStates.Clear(); _receivedLaunchPayload = false; @@ -46,7 +46,7 @@ private static void ResetRemoteCoopStateLocked() _lastLoggedClientContinueBlockReason = string.Empty; } - private static void SendCoopStateToRemote() + internal static void SendCoopStateToRemote() { var net = NetRef; if (net == null || !net.IsAlive) @@ -65,14 +65,14 @@ private static void SendCoopStateToRemote() } } - private static void NotifyMultiplayerSaveSlotChanged() + internal static void NotifyMultiplayerSaveSlotChanged() { InvalidateLocalContinueSaveStateCache(); SendCoopStateToRemote(); RequestLobbyMenuRefresh(); } - private static void PrepareCoopIdentityForPendingLaunch(PendingLaunchAction action) + internal static void PrepareCoopIdentityForPendingLaunch(PendingLaunchAction action) { if (_role != NetRole.Host) return; @@ -115,7 +115,7 @@ private static void PrepareCoopIdentityForPendingLaunch(PendingLaunchAction acti SendCoopStateToRemote(); } - private static void TryStoreRemoteCoopIdForPendingNewGame() + internal static void TryStoreRemoteCoopIdForPendingNewGame() { string? remoteCoopId; int? seed; @@ -160,7 +160,7 @@ private static void TryStoreRemoteCoopIdForPendingNewGame() SendCoopStateToRemote(); } - private static bool CanHostStartContinue(out string reason) + internal static bool CanHostStartContinue(out string reason) { if (!AllPlayersReady()) { @@ -171,7 +171,7 @@ private static bool CanHostStartContinue(out string reason) return IsHostContinueCompatible(out reason); } - private static bool IsHostContinueCompatible(out string reason) + internal static bool IsHostContinueCompatible(out string reason) { if (!TryGetLocalContinueReadiness(out var localCoopId, out reason)) return false; @@ -240,7 +240,7 @@ private static bool IsHostContinueCompatible(out string reason) return true; } - private static bool CanClientAcceptContinueLaunchLocked(out string reason) + internal static bool CanClientAcceptContinueLaunchLocked(out string reason) { if (!TryGetLocalContinueReadiness(out var localCoopId, out reason)) return false; @@ -273,7 +273,7 @@ private static bool CanClientAcceptContinueLaunchLocked(out string reason) return true; } - private static bool TryGetLocalContinueReadiness(out string localCoopId, out string reason) + internal static bool TryGetLocalContinueReadiness(out string localCoopId, out string reason) { localCoopId = string.Empty; @@ -292,7 +292,7 @@ private static bool TryGetLocalContinueReadiness(out string localCoopId, out str return true; } - private static bool HasLocalContinueSaveState(out string reason) + internal static bool HasLocalContinueSaveState(out string reason) { var slot = ResolveCurrentSaveSlotForCache(); var now = Stopwatch.GetTimestamp(); @@ -320,7 +320,7 @@ private static bool HasLocalContinueSaveState(out string reason) return hasSave; } - private static bool ReadLocalContinueSaveState(out string reason) + internal static bool ReadLocalContinueSaveState(out string reason) { try { @@ -347,7 +347,7 @@ private static bool ReadLocalContinueSaveState(out string reason) } } - private static void InvalidateLocalContinueSaveStateCache() + internal static void InvalidateLocalContinueSaveStateCache() { lock (Sync) { @@ -359,7 +359,7 @@ private static void InvalidateLocalContinueSaveStateCache() } } - private static int ResolveCurrentSaveSlotForCache() + internal static int ResolveCurrentSaveSlotForCache() { try { @@ -374,7 +374,7 @@ private static int ResolveCurrentSaveSlotForCache() return 0; } - private static void LogClientContinueBlockReasonLocked(string reason) + internal static void LogClientContinueBlockReasonLocked(string reason) { if (string.Equals(_lastLoggedClientContinueBlockReason, reason, StringComparison.Ordinal)) return; @@ -383,7 +383,7 @@ private static void LogClientContinueBlockReasonLocked(string reason) _log?.Warning("[NetMod] Continue Coop blocked on client: {Reason}", reason); } - private static string GetRemoteHostIdentity() + internal static string GetRemoteHostIdentity() { if (_steamHostSteamId != 0UL) return _steamHostSteamId.ToString(CultureInfo.InvariantCulture); @@ -393,7 +393,7 @@ private static string GetRemoteHostIdentity() : _remoteUsername.Trim(); } - private readonly struct RemoteCoopState + internal readonly struct RemoteCoopState { public readonly string? CoopId; public readonly bool HasContinueSave; diff --git a/UI/GameMenu.Connection.cs b/UI/ConnectionUI/LobbySession.Connection.cs similarity index 81% rename from UI/GameMenu.Connection.cs rename to UI/ConnectionUI/LobbySession.Connection.cs index 7840571..66e2a3f 100644 --- a/UI/GameMenu.Connection.cs +++ b/UI/ConnectionUI/LobbySession.Connection.cs @@ -13,7 +13,7 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { internal static void AbortClientWorldSync(string reason) { @@ -21,7 +21,7 @@ internal static void AbortClientWorldSync(string reason) return; var safeReason = string.IsNullOrWhiteSpace(reason) ? "authoritative world sync failed" : reason.Trim(); - EnqueueCriticalMainThreadCoalesced("game:abort-world-desync", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:abort-world-desync", () => { if (CurrentRole != NetRole.Client) return; @@ -45,7 +45,7 @@ internal static void AbortClientWorldSync(string reason) }); } - private static void ForceExitToMainMenu() + internal static void ForceExitToMainMenu() { try { @@ -87,7 +87,7 @@ private static void ForceExitToMainMenu() } } - private static void ShowHostStatusMenu(TitleScreen screen) + internal static void ShowHostStatusMenu(TitleScreen screen) { if (_menuRebuildDepth > 0) return; @@ -100,6 +100,11 @@ private static void ShowHostStatusMenu(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); + UiBegin(); + if (_menuTransport == ConnectionTransport.Steam) + UiInfo(Localize("Steam lobby — invite friends, then launch when everyone is Ready."), 0x9098A8); + else + UiInfo(Localize("LAN lobby — waiting for players. Launch unlocks when everyone is Ready."), 0x9098A8); var multiplayerSaveLabel = GetMultiplayerSaveButtonLabel(); var continueLabel = GetContinueButtonLabel(screen); var startLabel = GetStartNormalModeButtonLabel(); @@ -107,40 +112,36 @@ private static void ShowHostStatusMenu(TitleScreen screen) var continueCompatible = IsHostContinueCompatible(out var continueBlockReason); var canContinue = canLaunch && continueCompatible; var disabledContinueReason = canLaunch ? continueBlockReason : "Not all players ready"; - AddMenuButton(screen, continueLabel, () => ContinueHostRun(screen), canContinue ? Localize("Continue the selected multiplayer save") : Localize(disabledContinueReason), canContinue); - AddMenuButton(screen, startLabel, () => StartHostRunNormalMode(screen), GetText.Instance.GetString("Launch game"), canLaunch); - AddMenuButton(screen, "Custom Mode", () => OpenHostCustomMode(screen), Localize("Configure and launch multiplayer custom mode"), canLaunch); - AddMenuButton(screen, GetReadyButtonLabel(), () => ToggleLocalReadyFromMenu(screen), Localize("Toggle your ready state")); - AddMenuButton(screen, multiplayerSaveLabel, () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); + UiButton(continueLabel, () => ContinueHostRun(screen), canContinue ? Localize("Continue the selected multiplayer save") : Localize(disabledContinueReason), canContinue); + UiButton(startLabel, () => StartHostRunNormalMode(screen), GetText.Instance.GetString("Launch game"), canLaunch, 0x59D5FF); + UiButton("Custom Mode", () => OpenHostCustomMode(screen), Localize("Configure and launch multiplayer custom mode"), canLaunch); + UiButton(GetReadyButtonLabel(), () => ToggleLocalReadyFromMenu(screen), Localize("Toggle your ready state")); + UiButton(multiplayerSaveLabel, () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); if (_menuTransport == ConnectionTransport.Steam && _steamLobbyId != 0UL) { - AddMenuButton( - screen, + UiButton( Localize("Invite Steam friends"), () => OpenSteamHostInviteOverlay(screen), Localize("Open the Steam friends invite list")); } - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => + UiButton(GetText.Instance.GetString("Back"), () => { + var transport = _menuTransport; StopNetworkFromMenu(); SetRole(NetRole.None); _menuSelection = NetRole.None; - ShowMultiplayerMenu(screen); - screen.ShouldAutoHideConnectionUI(false); + // Return to the setup screen that led here — never hide ConnectionUI after Show*. + if (transport == ConnectionTransport.Lan) + ShowConnectionMenu(screen, NetRole.Host); + else + ShowHostTransportMenu(screen); + screen.ShouldAutoHideConnectionUI(true); }, GetText.Instance.GetString("Back to host setup")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst( - screen, - continueLabel, - startLabel, - "Custom Mode", - GetReadyButtonLabel(), - multiplayerSaveLabel, - Localize("Invite Steam friends"), - GetText.Instance.GetString("Back")); _inHostStatusMenu = true; _inClientWaitingMenu = false; + UiCommit(showLobby: true); } catch (Exception ex) { @@ -154,7 +155,7 @@ private static void ShowHostStatusMenu(TitleScreen screen) } } - private static void OpenSteamHostInviteOverlay(TitleScreen screen) + internal static void OpenSteamHostInviteOverlay(TitleScreen screen) { if (SteamConnect.TryOpenInviteOverlay(_steamLobbyId, out var error)) return; @@ -167,7 +168,7 @@ private static void OpenSteamHostInviteOverlay(TitleScreen screen) () => ShowHostStatusMenu(screen)); } - private static void ShowClientWaitingMenu(TitleScreen screen) + internal static void ShowClientWaitingMenu(TitleScreen screen) { if (_menuRebuildDepth > 0) return; @@ -180,20 +181,34 @@ private static void ShowClientWaitingMenu(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); - AddInfoLine(screen, $"Selected mode: {GetPendingLaunchSummaryLabel(screen)}", infoColor: 0xE0E0E0); - AddMenuButton(screen, GetReadyButtonLabel(), () => ToggleLocalReadyFromMenu(screen), Localize("Toggle your ready state")); + UiBegin(); + UiInfo(Localize("Connected — ready up and wait for the host to launch."), 0x9098A8); + UiInfo($"Selected mode: {GetPendingLaunchSummaryLabel(screen)}", 0xE0E0E0); + if (_menuTransport == ConnectionTransport.Steam) + UiInfo(Localize("Transport: Steam"), 0x8A93A6); + else + UiInfo(Localize("Transport: LAN"), 0x8A93A6); + UiButton(GetReadyButtonLabel(), () => ToggleLocalReadyFromMenu(screen), Localize("Toggle your ready state")); var multiplayerSaveLabel = GetMultiplayerSaveButtonLabel(); - AddMenuButton(screen, multiplayerSaveLabel, () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); - AddMenuButton( - screen, + UiButton(multiplayerSaveLabel, () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); + UiButton( GetText.Instance.GetString("Disconnect"), - () => {DisconnectFromMenu(screen); screen.ShouldAutoHideConnectionUI(false);}, - GetText.Instance.GetString("Disconnect and return to main menu")); + () => + { + var transport = _menuTransport; + DisconnectFromMenu(screen, restoreTitle: false); + if (transport == ConnectionTransport.Lan) + ShowConnectionMenu(screen, NetRole.Client); + else + ShowJoinTransportMenu(screen); + screen.ShouldAutoHideConnectionUI(true); + }, + GetText.Instance.GetString("Disconnect and return to join options")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst(screen, GetReadyButtonLabel(), multiplayerSaveLabel, GetText.Instance.GetString("Disconnect")); _inClientWaitingMenu = true; _inHostStatusMenu = false; + UiCommit(showLobby: true); } catch (Exception ex) { @@ -207,7 +222,7 @@ private static void ShowClientWaitingMenu(TitleScreen screen) } } - private static void ShowLobbyNotFoundPopup(TitleScreen screen) + internal static void ShowLobbyNotFoundPopup(TitleScreen screen) { var prevSuppress = _suppressAutoButton; _suppressAutoButton = true; @@ -217,17 +232,17 @@ private static void ShowLobbyNotFoundPopup(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); - AddInfoLine(screen, GetText.Instance.GetString("Can't find lobby"), infoColor: 0xFF9090); - AddMenuButton( - screen, + UiBegin(); + UiInfo(GetText.Instance.GetString("Can't find lobby"), 0xFF9090); + UiButton( GetText.Instance.GetString("OK"), () => ShowConnectionMenu(screen, NetRole.Client), GetText.Instance.GetString("Return to join menu")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst(screen, GetText.Instance.GetString("OK")); _inClientWaitingMenu = false; _inHostStatusMenu = false; + UiCommit(); } catch (Exception ex) { @@ -240,7 +255,7 @@ private static void ShowLobbyNotFoundPopup(TitleScreen screen) } } - private static void DisconnectFromMenu(TitleScreen screen) + internal static void DisconnectFromMenu(TitleScreen screen, bool restoreTitle = true) { StopNetworkFromMenu(); ResetClientConnectState(); @@ -248,10 +263,23 @@ private static void DisconnectFromMenu(TitleScreen screen) ResetSteamState(); _inHostStatusMenu = false; _inClientWaitingMenu = false; - screen.mainMenu(); + if (restoreTitle) + { + try + { + ConnectionUI.DismissAndHide(); + SetIsMainMenu(screen, false); + try { screen.clearMenu(); } catch { } + screen.mainMenu(); + } + catch + { + try { screen.mainMenu(); } catch { } + } + } } - private static void StopNetworkFromMenu() + internal static void StopNetworkFromMenu() { ResetHostDisconnectCountdown(); try @@ -268,7 +296,7 @@ private static void StopNetworkFromMenu() ResetSteamState(); } - private static void EditUsername(TitleScreen screen) + internal static void EditUsername(TitleScreen screen) { OpenTextInput(screen, GetText.Instance.GetString("Username"), _username, value => { @@ -360,7 +388,7 @@ public static void NotifyRemoteDisconnected(NetRole role) if (ts != null) ShowHostStatusMenu(ts); } - EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); + MainThreadPump.EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); RequestLobbyMenuRefresh(); return; } @@ -390,11 +418,11 @@ public static void NotifyRemoteDisconnected(NetRole role) if (wasInRun) StartHostDisconnectCountdown(savePending: !saved); - EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); + MainThreadPump.EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); RequestLobbyMenuRefresh(); } - private static void SendUsernameToRemote() + internal static void SendUsernameToRemote() { var net = NetRef; if (net == null || !net.HasRemote) return; @@ -409,7 +437,7 @@ private static void SendUsernameToRemote() } } - private static void SendCachedDataToRemote() + internal static void SendCachedDataToRemote() { var net = NetRef; if (net == null) return; @@ -427,7 +455,7 @@ private static void SendCachedDataToRemote() } } - private static bool AllPlayersReady() + internal static bool AllPlayersReady() { RefreshPlayersDisplayFromNetwork(); if (_playersDisplay.Count == 0) @@ -435,7 +463,7 @@ private static bool AllPlayersReady() return _playersDisplay.All(p => p.Ready); } - private static void ClearNetworkCaches() + internal static void ClearNetworkCaches() { lock (Sync) { @@ -445,7 +473,7 @@ private static void ClearNetworkCaches() } } - private static void ResetSteamState() + internal static void ResetSteamState() { var lobbyId = _steamLobbyId; if (lobbyId != 0UL) @@ -461,7 +489,7 @@ private static void ResetSteamState() _menuTransport = ConnectionTransport.Lan; } - private static void StartHostDisconnectCountdown(bool savePending = false) + internal static void StartHostDisconnectCountdown(bool savePending = false) { var now = DateTime.UtcNow; _hostDisconnectCountdownActive = true; @@ -483,7 +511,7 @@ private static void StartHostDisconnectCountdown(bool savePending = false) MultiplayerUI.PushSystemMessage(FormatLocalized("Back to menu in {0}...", HostDisconnectCountdownSeconds)); } - private static void ResetHostDisconnectCountdown() + internal static void ResetHostDisconnectCountdown() { _hostDisconnectCountdownActive = false; _hostDisconnectCountdownGameRef = null; @@ -495,7 +523,7 @@ private static void ResetHostDisconnectCountdown() _hostDisconnectSaveDeadline = DateTime.MinValue; } - private static void UpdateHostDisconnectCountdown() + internal static void UpdateHostDisconnectCountdown() { if (!_hostDisconnectCountdownActive) return; @@ -548,7 +576,7 @@ private static void UpdateHostDisconnectCountdown() ForceExitToMainMenu(); } - private static void CaptureHostDisconnectCountdownGame() + internal static void CaptureHostDisconnectCountdownGame() { try { @@ -561,7 +589,7 @@ private static void CaptureHostDisconnectCountdownGame() } } - private static bool IsHostDisconnectCountdownGameStillActive() + internal static bool IsHostDisconnectCountdownGameStillActive() { var gameRef = _hostDisconnectCountdownGameRef; if (gameRef == null) @@ -591,7 +619,7 @@ private static bool IsHostDisconnectCountdownGameStillActive() } } - private static bool TrySaveClientWorldBeforeHostAutoExit(string reason) + internal static bool TrySaveClientWorldBeforeHostAutoExit(string reason) { try { @@ -622,7 +650,7 @@ private static bool TrySaveClientWorldBeforeHostAutoExit(string reason) } } - private static bool TryValidateClientMultiplayerSave(out string error) + internal static bool TryValidateClientMultiplayerSave(out string error) { error = string.Empty; try @@ -652,7 +680,7 @@ internal static string Localize(string message) return GetText.Instance.GetString(message); } - private static string FormatLocalized(string format, params object[] args) + internal static string FormatLocalized(string format, params object[] args) { var localizedFormat = Localize(format); try @@ -720,13 +748,13 @@ public static void ReceiveGeneratePayload(string json) } } - private static bool IsChallengeLevel(string levelId) + internal static bool IsChallengeLevel(string levelId) { if (string.IsNullOrWhiteSpace(levelId)) return false; return levelId.IndexOf("challenge", StringComparison.OrdinalIgnoreCase) >= 0; } - private sealed class LevelDescSync + internal sealed class LevelDescSync { public string LevelId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; @@ -746,7 +774,7 @@ private sealed class LevelDescSync public int Group { get; set; } } - private sealed class MenuConfig + internal sealed class MenuConfig { public string user { get; set; } = "guest"; public string last_ip { get; set; } = "127.0.0.1"; @@ -754,7 +782,7 @@ private sealed class MenuConfig public string player_id { get; set; } = Guid.NewGuid().ToString("N"); } - private sealed class PlayerInfo + internal sealed class PlayerInfo { public int UserId { get; set; } public string Name { get; set; } = "guest"; @@ -762,7 +790,7 @@ private sealed class PlayerInfo public bool IsHost { get; set; } } - private static void LoadConfig() + internal static void LoadConfig() { try { @@ -793,7 +821,7 @@ private static void LoadConfig() SaveConfig(); } - private static void SaveConfig() + internal static void SaveConfig() { try { @@ -815,7 +843,7 @@ private static void SaveConfig() } } - private static string GetConfigPath() + internal static string GetConfigPath() { var baseDir = AppContext.BaseDirectory; var root = Directory.GetParent(baseDir)?.Parent?.Parent?.Parent?.FullName ?? baseDir; @@ -823,14 +851,14 @@ private static string GetConfigPath() return Path.Combine(dir, "config.json"); } - private static string CleanUsername(string? value) + internal static string CleanUsername(string? value) { var cleaned = string.IsNullOrWhiteSpace(value) ? "guest" : value.Trim(); cleaned = cleaned.Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); return cleaned.Length == 0 ? "guest" : cleaned; } - private static string GetDefaultUsername() + internal static string GetDefaultUsername() { var steamName = TryGetSteamPersonaName(); if (!string.IsNullOrWhiteSpace(steamName)) @@ -845,7 +873,7 @@ private static string GetDefaultUsername() return "guest"; } - private static string? TryGetSteamPersonaName() + internal static string? TryGetSteamPersonaName() { string? steamPath = null; try @@ -881,7 +909,7 @@ private static string GetDefaultUsername() return TryParseMostRecentPersonaName(loginUsersPath); } - private static string? TryParseMostRecentPersonaName(string path) + internal static string? TryParseMostRecentPersonaName(string path) { try { @@ -949,7 +977,7 @@ private static string GetDefaultUsername() return null; } - private static bool IsQuotedKeyOnly(string line) + internal static bool IsQuotedKeyOnly(string line) { if (!line.StartsWith("\"", StringComparison.Ordinal)) return false; @@ -960,7 +988,7 @@ private static bool IsQuotedKeyOnly(string line) return thirdQuote < 0; } - private static bool TryParseVdfPair(string line, out string key, out string value) + internal static bool TryParseVdfPair(string line, out string key, out string value) { key = string.Empty; value = string.Empty; @@ -980,7 +1008,7 @@ private static bool TryParseVdfPair(string line, out string key, out string valu return true; } - private static void ResetClientConnectState() + internal static void ResetClientConnectState() { lock (Sync) { @@ -1052,12 +1080,12 @@ internal static void HandleTextInputClipboardShortcuts() } } - private static bool IsCtrlDown() + internal static bool IsCtrlDown() { return dc.hxd.Key.Class.isDown(KeyCtrl) || dc.hxd.Key.Class.isDown(KeyLCtrl) || dc.hxd.Key.Class.isDown(KeyRCtrl); } - private static void RegisterActiveTextInput(TextInput input, bool noSpaces) + internal static void RegisterActiveTextInput(TextInput input, bool noSpaces) { lock (TextInputSync) { @@ -1066,7 +1094,7 @@ private static void RegisterActiveTextInput(TextInput input, bool noSpaces) } } - private static void ClearActiveTextInput() + internal static void ClearActiveTextInput() { lock (TextInputSync) { @@ -1075,7 +1103,7 @@ private static void ClearActiveTextInput() } } - private static TextInput? GetActiveTextInput() + internal static TextInput? GetActiveTextInput() { lock (TextInputSync) { @@ -1086,7 +1114,7 @@ private static void ClearActiveTextInput() return null; } - private static bool IsTextInputActive(TextInput input) + internal static bool IsTextInputActive(TextInput input) { var active = GetMemberValue(input, "isActive", true) ?? GetMemberValue(input, "active", true); if (active is bool activeBool) @@ -1104,7 +1132,7 @@ private static bool IsTextInputActive(TextInput input) return true; } - private static object? GetTextInputTarget(TextInput input) + internal static object? GetTextInputTarget(TextInput input) { return GetMemberValue(input, "input", true) ?? GetMemberValue(input, "textInput", true) @@ -1112,7 +1140,7 @@ private static bool IsTextInputActive(TextInput input) ?? input; } - private static bool TryGetTextInputValue(TextInput input, out string text) + internal static bool TryGetTextInputValue(TextInput input, out string text) { text = string.Empty; var target = GetTextInputTarget(input); @@ -1135,7 +1163,7 @@ private static bool TryGetTextInputValue(TextInput input, out string text) return true; } - private static bool TrySetTextInputValue(TextInput input, string text) + internal static bool TrySetTextInputValue(TextInput input, string text) { var target = GetTextInputTarget(input); if (target == null) @@ -1153,7 +1181,7 @@ private static bool TrySetTextInputValue(TextInput input, string text) || TrySetMember(target, "str", text); } - private static bool TryInvokeTextInputSetter(object target, object value) + internal static bool TryInvokeTextInputSetter(object target, object value) { var type = target.GetType(); var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase; @@ -1177,7 +1205,7 @@ private static bool TryInvokeTextInputSetter(object target, object value) return false; } - private static void RemoveSpacesFromTextInput(TextInput input) + internal static void RemoveSpacesFromTextInput(TextInput input) { if (!TryGetTextInputValue(input, out var text)) return; @@ -1188,12 +1216,12 @@ private static void RemoveSpacesFromTextInput(TextInput input) TrySetTextInputValue(input, RemoveSpaces(text)); } - private static string RemoveSpaces(string value) + internal static string RemoveSpaces(string value) { return value.Replace(" ", string.Empty, StringComparison.Ordinal); } - private static string? TryGetClipboardText() + internal static string? TryGetClipboardText() { try { @@ -1232,7 +1260,7 @@ private static string RemoveSpaces(string value) } } - private static bool TrySetClipboardText(string text) + internal static bool TrySetClipboardText(string text) { try { @@ -1285,7 +1313,7 @@ private static bool TrySetClipboardText(string text) } } - private static void OpenTextInput(TitleScreen screen, string title, string initial, Action onValidate, bool noSpaces = false) + internal static void OpenTextInput(TitleScreen screen, string title, string initial, Action onValidate, bool noSpaces = false) { try { @@ -1293,29 +1321,19 @@ private static void OpenTextInput(TitleScreen screen, string title, string initi if (noSpaces && initial.Contains(' ', StringComparison.Ordinal)) initial = RemoveSpaces(initial); var initialText = initial ?? string.Empty; - var input = new TextInput( - screen, - MakeHLString(title), - MakeHLString(initialText), - MakeHLString(initialText), - new HlAction(s => + // Custom ConnectionUI prompt (stock TextInput dialog is too ugly for the hub). + ConnectionUI.ShowTextPrompt( + title, + initialText, + value => { - var text = s?.ToString() ?? string.Empty; + var text = value ?? string.Empty; if (noSpaces) text = RemoveSpaces(text); - try - { - onValidate(text); - } - finally - { - ClearActiveTextInput(); - } - }), - MakeHLString(GetText.Instance.GetString("OK")), - MakeHLString(GetText.Instance.GetString("Cancel")), - (dc.hxd.res.Sound?)null); - RegisterActiveTextInput(input, noSpaces); + onValidate(text); + }, + onCancel: null, + noSpaces: noSpaces); } catch (Exception ex) { @@ -1324,7 +1342,7 @@ private static void OpenTextInput(TitleScreen screen, string title, string initi } } - private static void TryAddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null, int? textColor = null) + internal static void TryAddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null, int? textColor = null) { try { @@ -1341,7 +1359,7 @@ private static void TryAddMenuButton(TitleScreen screen, string label, Action on /// vanilla white: this method builds Host game, Join game, Ready, Disconnect, OK, Back and /// most other entries, so a hardcoded accent here would recolour the entire mod UI. /// - private static void AddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null, bool? isEnabled = null, int? textColor = null) + internal static void AddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null, bool? isEnabled = null, int? textColor = null) { var cb = new HlAction(onClick); var labelStr = MakeHLString(label); @@ -1351,7 +1369,7 @@ private static void AddMenuButton(TitleScreen screen, string label, Action onCli screen.addMenu(labelStr, cb, helpStr, isEnabled, color); } - private static void AddInfoLine(TitleScreen screen, string text, int? infoColor = null) + internal static void AddInfoLine(TitleScreen screen, string text, int? infoColor = null) { int colorVal = infoColor ?? 0xFFFFFF; var labelStr = MakeHLString(text); @@ -1361,7 +1379,68 @@ private static void AddInfoLine(TitleScreen screen, string text, int? infoColor screen.addMenu(labelStr, cb, helpStr, false, color); } - private static object? GetMemberValue(object? obj, string name, bool ignoreCase) + // ---------------------------------------------------------------- ConnectionUI routing + + /// Starts a ConnectionUI screen (clears the pending model, makes the hub visible). + internal static void UiBegin() + { + ConnectionUI.BeginMenu(); + } + + /// Adds a pretty button to the current ConnectionUI screen. + internal static void UiButton(string label, Action onClick, string? help = null, bool? isEnabled = null, int? textColor = null, bool fieldStyle = false) + { + ConnectionUI.AddPendingButton(label, help ?? string.Empty, isEnabled ?? true, textColor ?? 0xFFFFFF, onClick, fieldStyle); + } + + /// Adds an informational line to the current ConnectionUI screen. + internal static void UiInfo(string text, int? infoColor = null) + { + ConnectionUI.AddPendingInfo(text, infoColor ?? 0xFFFFFF); + } + + /// Renders the current ConnectionUI screen. + internal static void UiCommit(bool showLobby = false, bool hubLayout = false) + { + ConnectionUI.CommitMenu(showLobby, hubLayout); + } + + /// + /// Leaves the multiplayer hub and forces a real TitleScreen main-menu rebuild. + /// ShowMultiplayerMenu clears menu items then restores isMainMenu=true, so a bare + /// mainMenu() can no-op and leave an empty title under a leftover hub. + /// + internal static void ReturnFromMultiplayerHubToTitle(TitleScreen screen) + { + try + { + StopNetworkFromMenu(); + ConnectionUI.DismissAndHide(); + // Force TitleScreen to treat this as a fresh main-menu build. + SetIsMainMenu(screen, false); + try { screen.clearMenu(); } catch { } + screen.mainMenu(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Return to title failed: {Message}", ex.Message); + try + { + ConnectionUI.DismissAndHide(); + SetIsMainMenu(screen, false); + screen.mainMenu(); + } + catch { } + } + } + + /// Shows the ConnectionUI lobby display (player list + lobby code). + internal static void UiLobby() + { + ConnectionUI.ShowLobbyMode(); + } + + internal static object? GetMemberValue(object? obj, string name, bool ignoreCase) { if (obj == null || string.IsNullOrWhiteSpace(name)) return null; @@ -1381,7 +1460,7 @@ private static void AddInfoLine(TitleScreen screen, string text, int? infoColor return null; } - private static bool TrySetMember(object? obj, string name, object? value) + internal static bool TrySetMember(object? obj, string name, object? value) { if (obj == null || string.IsNullOrWhiteSpace(name)) return false; @@ -1408,12 +1487,12 @@ private static bool TrySetMember(object? obj, string name, object? value) return false; } - private static dc.String MakeHLString(string value) + internal static dc.String MakeHLString(string value) { return value.AsHaxeString(); } - private static bool GetIsMainMenu(TitleScreen screen) + internal static bool GetIsMainMenu(TitleScreen screen) { try { @@ -1424,7 +1503,7 @@ private static bool GetIsMainMenu(TitleScreen screen) return false; } - private static void SetIsMainMenu(TitleScreen screen, bool value) + internal static void SetIsMainMenu(TitleScreen screen, bool value) { try { @@ -1433,7 +1512,7 @@ private static void SetIsMainMenu(TitleScreen screen, bool value) catch { } } - private static int GetArrayLength(object arrObj) + internal static int GetArrayLength(object arrObj) { try { @@ -1445,7 +1524,7 @@ private static int GetArrayLength(object arrObj) return 0; } - private static int FindMenuIndexByLabel(object? arrObj, string label) + internal static int FindMenuIndexByLabel(object? arrObj, string label) { if (arrObj == null) return -1; try @@ -1467,7 +1546,7 @@ private static int FindMenuIndexByLabel(object? arrObj, string label) return -1; } - private static string GetMenuLabel(object? menuItem) + internal static string GetMenuLabel(object? menuItem) { if (menuItem == null) return string.Empty; @@ -1490,7 +1569,7 @@ private static string GetMenuLabel(object? menuItem) } } - private static void RemoveMenuItems(TitleScreen screen, params string[] labels) + internal static void RemoveMenuItems(TitleScreen screen, params string[] labels) { if (labels.Length == 0) return; var arrObj = GetMemberValue(screen, "menuItems", true); @@ -1533,7 +1612,7 @@ private static void RemoveMenuItems(TitleScreen screen, params string[] labels) } } - private static void RemoveDuplicatesKeepFirst(TitleScreen screen, params string[] labels) + internal static void RemoveDuplicatesKeepFirst(TitleScreen screen, params string[] labels) { if (labels.Length == 0) return; var arrObj = GetMemberValue(screen, "menuItems", true); @@ -1580,12 +1659,12 @@ private static void RemoveDuplicatesKeepFirst(TitleScreen screen, params string[ } - private static void StoreTitleScreen(TitleScreen ts) + internal static void StoreTitleScreen(TitleScreen ts) { _titleScreenRef = new WeakReference(ts); } - private static TitleScreen? GetTitleScreen() + internal static TitleScreen? GetTitleScreen() { if (_titleScreenRef != null && _titleScreenRef.TryGetTarget(out var ts)) return ts; diff --git a/UI/GameMenu.cs b/UI/ConnectionUI/LobbySession.cs similarity index 80% rename from UI/GameMenu.cs rename to UI/ConnectionUI/LobbySession.cs index 2910f4a..38707c3 100644 --- a/UI/GameMenu.cs +++ b/UI/ConnectionUI/LobbySession.cs @@ -14,71 +14,72 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { - private static readonly object Sync = new(); - private static ILogger? _log; - private static NetRole _role = NetRole.None; - private static bool _inActualRun; - private static int? _serverSeed; - private static int? _remoteSeed; - private static int? _pendingClientRestartSeed; - private static string _pendingClientRestartReason = string.Empty; - private const int MaxSeed = 999_999; + internal static readonly object Sync = new(); + internal static ILogger? _log; + internal static ILogger? Log => _log; + internal static NetRole _role = NetRole.None; + internal static bool _inActualRun; + internal static int? _serverSeed; + internal static int? _remoteSeed; + internal static int? _pendingClientRestartSeed; + internal static string _pendingClientRestartReason = string.Empty; + internal const int MaxSeed = 999_999; public static NetNode? NetRef { get; set; } - private static bool _menuHooksAttached; - private static bool _addMenuHookRegistered; - private static WeakReference? _titleScreenRef; - private static string _mpIp = "127.0.0.1"; - private static int _mpPort = 1234; - private static NetRole _menuSelection = NetRole.None; - private enum ConnectionTransport + internal static bool _menuHooksAttached; + internal static bool _addMenuHookRegistered; + internal static WeakReference? _titleScreenRef; + internal static string _mpIp = "127.0.0.1"; + internal static int _mpPort = 1234; + internal static NetRole _menuSelection = NetRole.None; + internal enum ConnectionTransport { Lan, Steam } - private static ConnectionTransport _menuTransport = ConnectionTransport.Lan; - private static SteamConnect.SteamLobbyVisibility _steamLobbyVisibility = + internal static ConnectionTransport _menuTransport = ConnectionTransport.Lan; + internal static SteamConnect.SteamLobbyVisibility _steamLobbyVisibility = SteamConnect.SteamLobbyVisibility.FriendsOnly; - private static ulong _steamLobbyId; - private static string _steamLobbyCode = string.Empty; - private static ulong _steamHostSteamId; - private static bool _steamJoinLobbyResolvePending; - private static ulong? _pendingOverlayJoinLobbyId; - private static bool _steamFriendJoinPageActive; - private static bool _steamFriendLobbyRefreshInFlight; - private static long _nextSteamFriendLobbyRefreshTicks; - private static string _steamFriendLobbySignature = string.Empty; - private static List _steamFriendLobbies = new(); - private const int SteamFriendLobbyRefreshMs = 2500; + internal static ulong _steamLobbyId; + internal static string _steamLobbyCode = string.Empty; + internal static ulong _steamHostSteamId; + internal static bool _steamJoinLobbyResolvePending; + internal static ulong? _pendingOverlayJoinLobbyId; + internal static bool _steamFriendJoinPageActive; + internal static bool _steamFriendLobbyRefreshInFlight; + internal static long _nextSteamFriendLobbyRefreshTicks; + internal static string _steamFriendLobbySignature = string.Empty; + internal static List _steamFriendLobbies = new(); + internal const int SteamFriendLobbyRefreshMs = 2500; internal const int ClientConnectMaxAttempts = 3; - private static bool _pendingAutoStart; - private static bool _autoStartTriggered; - private static bool _continueLaunchInProgress; - private static DateTime _continueLaunchStartedAt = DateTime.MinValue; - private const int ContinueLaunchGuardMs = 6000; - private static DateTime _autoStartRetryAt = DateTime.MinValue; - private const int DeathRestartCooldownMs = 1000; - private static DateTime _deathRestartCooldownUntil = DateTime.MinValue; - private const string AutoStartMutexName = "DeadCellsMultiplayerMod.AutoStart"; - private static bool _mainMenuButtonAdded; - private static bool _suppressAutoButton; - private static bool _worldExitHandled; - private static bool _hostDisconnectCountdownActive; - private static WeakReference? _hostDisconnectCountdownGameRef; - private static DateTime _hostDisconnectCountdownUntil = DateTime.MinValue; - private static int _lastHostDisconnectCountdown = -1; - private const int HostDisconnectCountdownSeconds = 5; - private static bool _hostDisconnectSavePending; - private static DateTime _hostDisconnectSaveRetryAt = DateTime.MinValue; - private static DateTime _hostDisconnectSaveDeadline = DateTime.MinValue; - private const int HostDisconnectSaveRetryMs = 500; - private const int HostDisconnectSaveMaxSeconds = 10; - private static bool _seedArrived; - private static string _username = "guest"; - private static string _remoteUsername = "guest"; - private static string _playerId = Guid.NewGuid().ToString("N"); + internal static bool _pendingAutoStart; + internal static bool _autoStartTriggered; + internal static bool _continueLaunchInProgress; + internal static DateTime _continueLaunchStartedAt = DateTime.MinValue; + internal const int ContinueLaunchGuardMs = 6000; + internal static DateTime _autoStartRetryAt = DateTime.MinValue; + internal const int DeathRestartCooldownMs = 1000; + internal static DateTime _deathRestartCooldownUntil = DateTime.MinValue; + internal const string AutoStartMutexName = "DeadCellsMultiplayerMod.AutoStart"; + internal static bool _mainMenuButtonAdded; + internal static bool _suppressAutoButton; + internal static bool _worldExitHandled; + internal static bool _hostDisconnectCountdownActive; + internal static WeakReference? _hostDisconnectCountdownGameRef; + internal static DateTime _hostDisconnectCountdownUntil = DateTime.MinValue; + internal static int _lastHostDisconnectCountdown = -1; + internal const int HostDisconnectCountdownSeconds = 5; + internal static bool _hostDisconnectSavePending; + internal static DateTime _hostDisconnectSaveRetryAt = DateTime.MinValue; + internal static DateTime _hostDisconnectSaveDeadline = DateTime.MinValue; + internal const int HostDisconnectSaveRetryMs = 500; + internal const int HostDisconnectSaveMaxSeconds = 10; + internal static bool _seedArrived; + internal static string _username = "guest"; + internal static string _remoteUsername = "guest"; + internal static string _playerId = Guid.NewGuid().ToString("N"); public static string Username => _username; public static string RemoteUsername => _remoteUsername; @@ -104,27 +105,27 @@ internal static bool TryCopySteamLobbyCodeFromUi() /// True while clipboard/overlay join is resolving the Steam lobby (before ). internal static bool IsSteamJoinLobbyResolvePending() => _steamJoinLobbyResolvePending; - private static bool _localReady; - private static List _playersDisplay = new(); - private static bool _inHostStatusMenu; - private static bool _inClientWaitingMenu; + internal static bool _localReady; + internal static List _playersDisplay = new(); + internal static bool _inHostStatusMenu; + internal static bool _inClientWaitingMenu; /// Prevents nested host/client status menu rebuilds when addMenu hook runs ProcessMainThreadQueue before orig. - private static int _menuRebuildDepth; - private static bool _genArrived; - private static LevelDescSync? _cachedLevelDescSync; - private static readonly object TextInputSync = new(); - private static WeakReference? _activeTextInputRef; - private static bool _activeTextInputNoSpaces; - private const int KeyCtrl = 17; - private const int KeyLCtrl = 162; - private const int KeyRCtrl = 163; - private const int KeyC = 67; - private const int KeyV = 86; - private const int KeySpace = 32; - private const int KeyEsc = 27; + internal static int _menuRebuildDepth; + internal static bool _genArrived; + internal static LevelDescSync? _cachedLevelDescSync; + internal static readonly object TextInputSync = new(); + internal static WeakReference? _activeTextInputRef; + internal static bool _activeTextInputNoSpaces; + internal const int KeyCtrl = 17; + internal const int KeyLCtrl = 162; + internal const int KeyRCtrl = 163; + internal const int KeyC = 67; + internal const int KeyV = 86; + internal const int KeySpace = 32; + internal const int KeyEsc = 27; // Win32 clipboard helpers for text input shortcuts. - private const uint CfUnicodeText = 13; - private const uint GmemMoveable = 0x0002; + internal const uint CfUnicodeText = 13; + internal const uint GmemMoveable = 0x0002; [DllImport("user32.dll")] private static extern bool OpenClipboard(IntPtr hWndNewOwner); [DllImport("user32.dll")] @@ -148,7 +149,7 @@ internal static bool TryCopySteamLobbyCodeFromUi() public static void Initialize(ILogger logger) { - logger.Information("\x1b[32m[[ModEntry.GameMenu] Initializing GameMenu...]\x1b[0m "); + logger.Information("\x1b[32m[[ModEntry.LobbySession] Initializing LobbySession...]\x1b[0m "); InitializeRunLaunchHandshake(logger); lock (Sync) { @@ -194,15 +195,16 @@ public static void Initialize(ILogger logger) ResetLobbyReadyStateLocked(); InvalidateGeneratePayloadCacheLocked(); ResetRunLaunchCompatStateLocked(); - ResetMainThreadQueuesLocked(); + MainThreadPump.ResetMainThreadQueuesLocked(); ResetClientLaunchSessionLocked(); } - InitializeMenuUiHooks(); + TitleMenuHooks.InitializeMenuUiHooks(); } public static void MarkInRun() { + int consumeSequence = 0; lock (Sync) { _inActualRun = true; @@ -211,9 +213,23 @@ public static void MarkInRun() _clientLevelGraphWaitStartedTicks = 0; _clientLevelGraphWaitExpired = false; MarkClientLaunchInRunLocked(); + + // Belt-and-suspenders: once the hero is live, the current remote execute/seed + // sequence is definitively consumed. Prevents late host rebroadcasts from + // forcing unconsumed_host_launch restarts on an already-built world. + consumeSequence = Math.Max(_structuredLaunchExecuteSequence, _remoteSeedSequence); + if (consumeSequence > 0) + MarkRemoteLaunchSequenceConsumedLocked(consumeSequence); } ClearClientRestartPending(); + if (consumeSequence > 0) + { + _log?.Information( + "[NetMod][RunLaunch] Marked remote launch consumed seq={Sequence} (mark_in_run)", + consumeSequence); + } + // Terminal launch signal, outside the lock because it performs a network send. // On the client this also stops the host's launch beacon; on the host it publishes the // run-live state that a late joiner replays. @@ -263,7 +279,7 @@ public static void SetRole(NetRole role) if (previous == NetRole.Client && role != NetRole.Client) { GameDataSync.SwapToLocalSerializerSync(); - EnqueueCriticalMainThreadCoalesced("game:restore-original-user", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:restore-original-user", () => { try { @@ -350,7 +366,7 @@ internal static void QueueHostRestartFromDeath(string reason) _deathRestartCooldownUntil = now.AddMilliseconds(DeathRestartCooldownMs); } - EnqueueCriticalMainThreadCoalesced("game:host-restart", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:host-restart", () => { ModEntry.ResetDownedPlayersForRestart(); @@ -389,7 +405,7 @@ internal static void QueueHostRestartFromDeath(string reason) }); } - private static void RestartCurrentWorldWithLoading(dc.pr.Game game, dc.LaunchMode launchMode) + internal static void RestartCurrentWorldWithLoading(dc.pr.Game game, dc.LaunchMode launchMode) { var main = dc.Main.Class.ME; if (main == null) @@ -399,7 +415,7 @@ private static void RestartCurrentWorldWithLoading(dc.pr.Game game, dc.LaunchMod main.launchGame(launchMode, null, null); } - private static void PrepareCurrentWorldForRestartTransition(dc.pr.Game game) + internal static void PrepareCurrentWorldForRestartTransition(dc.pr.Game game) { // A restart tears the world down exactly like a biome transition does, so it needs the // same fence: freeze mob sync mutation before the old registries are destroyed, and let @@ -409,11 +425,17 @@ private static void PrepareCurrentWorldForRestartTransition(dc.pr.Game game) try { ModEntry.Instance?.DisposeCoopGhostRuntimeForWorldTeardown(game); } catch { } + // Client same-run restart tears down PrisonStart via Level.onDispose. Pre-dispose + // Homunculi / heal hero.controller here so the later native GC pass cannot hit + // Homunculus.dispose's unconditional hero.controller.manualLock write. + try { ModEntry.PrepareLevelProcessTeardown(game.curLevel, "client_restart_prepare"); } catch { } + try { var cine = game.curCine; if (cine != null) { + try { ModEntry.TryAssignProcessController(cine, game); } catch { } try { cine.destroyed = true; } catch { } try { cine.disposeImmediately(); } catch { } if (ReferenceEquals(game.curCine, cine)) @@ -434,10 +456,10 @@ private static void PrepareCurrentWorldForRestartTransition(dc.pr.Game game) } } - private static void QueueClientRestartFromHostSeed(int seed, string reason) + internal static void QueueClientRestartFromHostSeed(int seed, string reason) { MarkClientRestartPending(); - EnqueueCriticalMainThreadCoalesced("game:client-restart", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:client-restart", () => { ModEntry.ResetDownedPlayersForRestart(); @@ -472,7 +494,7 @@ private static void QueueClientRestartFromHostSeed(int seed, string reason) }); } - private static void TryProcessPendingClientRestart() + internal static void TryProcessPendingClientRestart() { int seed; string reason; @@ -558,7 +580,7 @@ public static void ReceiveRemoteUsername(string username) RequestLobbyMenuRefresh(); } - private static void SendCachedGeneratePayload() + internal static void SendCachedGeneratePayload() { var net = NetRef; if (net == null) return; @@ -574,7 +596,7 @@ private static void SendCachedGeneratePayload() net.SendGeneratePayload(json); } - private static void CacheLevelDescSync(LevelDescSync? sync) + internal static void CacheLevelDescSync(LevelDescSync? sync) { lock (Sync) { @@ -582,7 +604,7 @@ private static void CacheLevelDescSync(LevelDescSync? sync) } } - private static LevelDescSync? GetCachedLevelDescSync() + internal static LevelDescSync? GetCachedLevelDescSync() { lock (Sync) { @@ -678,7 +700,7 @@ public static void TickMenu(double dt) } } - private static void NotifyLevelDescReceived() + internal static void NotifyLevelDescReceived() { lock (Sync) { @@ -687,7 +709,7 @@ private static void NotifyLevelDescReceived() } } - private static void ShowMultiplayerMenu(TitleScreen screen) + internal static void ShowMultiplayerMenu(TitleScreen screen) { _steamFriendJoinPageActive = false; var prevSuppress = _suppressAutoButton; @@ -697,25 +719,26 @@ private static void ShowMultiplayerMenu(TitleScreen screen) { SetIsMainMenu(screen, false); screen.clearMenu(); - AddMenuButton( - screen, + UiBegin(); + UiInfo(Localize("Co-op multiplayer"), 0xF7FC65); + UiInfo(Localize("Host a session or join a friend over LAN or Steam."), 0x9098A8); + UiButton( GetText.Instance.GetString("Host game"), () => ShowHostTransportMenu(screen), GetText.Instance.GetString("Create a multiplayer session")); - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("Join game"), () => ShowJoinTransportMenu(screen), GetText.Instance.GetString("Connect to an existing host")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => - { - StopNetworkFromMenu(); - screen.mainMenu(); - }, GetText.Instance.GetString("Return to main menu")); + UiButton( + GetText.Instance.GetString("Back"), + () => ReturnFromMultiplayerHubToTitle(screen), + GetText.Instance.GetString("Return to main menu")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst(screen, GetText.Instance.GetString("Host game"), GetText.Instance.GetString("Join game")); _inHostStatusMenu = false; _inClientWaitingMenu = false; + // hubLayout: only this first Host/Join screen uses the wide centered panel. + UiCommit(hubLayout: true); } catch (Exception ex) { @@ -728,7 +751,7 @@ private static void ShowMultiplayerMenu(TitleScreen screen) } } - private static void ShowHostTransportMenu(TitleScreen screen) + internal static void ShowHostTransportMenu(TitleScreen screen) { _steamFriendJoinPageActive = false; var prevSuppress = _suppressAutoButton; @@ -739,30 +762,24 @@ private static void ShowHostTransportMenu(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); - AddMenuButton( - screen, + UiBegin(); + UiInfo(Localize("Host options"), 0xF7FC65); + UiInfo(Localize("LAN uses IP/port. Steam uses friends or a lobby code."), 0x9098A8); + UiButton( GetText.Instance.GetString("Lan host"), () => ShowConnectionMenu(screen, NetRole.Host), GetText.Instance.GetString("Use direct IP/port hosting")); - - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("Steam host"), () => ShowSteamHostModeMenu(screen), GetText.Instance.GetString("Choose who can discover the Steam lobby")); - - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("Back"), () => ShowMultiplayerMenu(screen), GetText.Instance.GetString("Back to multiplayer menu")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst( - screen, - GetText.Instance.GetString("Lan host"), - GetText.Instance.GetString("Steam host"), - GetText.Instance.GetString("Back")); + UiCommit(); } catch (Exception ex) { @@ -775,7 +792,7 @@ private static void ShowHostTransportMenu(TitleScreen screen) } } - private static void ShowSteamHostModeMenu(TitleScreen screen) + internal static void ShowSteamHostModeMenu(TitleScreen screen) { _steamFriendJoinPageActive = false; var prevSuppress = _suppressAutoButton; @@ -786,30 +803,26 @@ private static void ShowSteamHostModeMenu(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); - AddMenuButton( - screen, + UiBegin(); + UiInfo(Localize("Steam lobby visibility"), 0xF7FC65); + UiInfo(Localize("Friends-only stays private. Public creates a shareable lobby code."), 0x9098A8); + UiButton( GetText.Instance.GetString("Steam friends-only host"), () => StartSteamHost(screen, SteamConnect.SteamLobbyVisibility.FriendsOnly), - GetText.Instance.GetString("Create a Steam lobby visible to friends")); - - AddMenuButton( - screen, + GetText.Instance.GetString("Create a Steam lobby visible to friends"), + textColor: 0x59D5FF); + UiButton( GetText.Instance.GetString("Steam public host"), () => StartSteamHost(screen, SteamConnect.SteamLobbyVisibility.Public), - GetText.Instance.GetString("Create a public Steam lobby with a shareable code")); - - AddMenuButton( - screen, + GetText.Instance.GetString("Create a public Steam lobby with a shareable code"), + textColor: 0x59D5FF); + UiButton( GetText.Instance.GetString("Back"), () => ShowHostTransportMenu(screen), GetText.Instance.GetString("Back to hosting options")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst( - screen, - GetText.Instance.GetString("Steam friends-only host"), - GetText.Instance.GetString("Steam public host"), - GetText.Instance.GetString("Back")); + UiCommit(); } catch (Exception ex) { @@ -822,7 +835,7 @@ private static void ShowSteamHostModeMenu(TitleScreen screen) } } - private static void ShowJoinTransportMenu(TitleScreen screen) + internal static void ShowJoinTransportMenu(TitleScreen screen) { _steamFriendJoinPageActive = true; @@ -834,8 +847,13 @@ private static void ShowJoinTransportMenu(TitleScreen screen) SetIsMainMenu(screen, false); screen.clearMenu(); - AddMenuButton( - screen, + UiBegin(); + UiInfo(Localize("Join a session"), 0xF7FC65); + if (_steamFriendLobbies.Count > 0) + UiInfo(Localize("Friends hosting right now appear below."), 0x9098A8); + else + UiInfo(Localize("No friends currently hosting — list refreshes automatically."), 0x9098A8); + UiButton( GetText.Instance.GetString("Lan join"), () => { @@ -844,14 +862,7 @@ private static void ShowJoinTransportMenu(TitleScreen screen) }, GetText.Instance.GetString("Connect by IP/port")); - AddInfoLine(screen, Localize("Steam friends hosting this mod"), infoColor: 0xA8D8FF); - - if (_steamFriendLobbies.Count == 0) - { - AddInfoLine(screen, Localize("No Steam friends are hosting right now."), infoColor: 0xC8C8C8); - AddInfoLine(screen, Localize("This list refreshes automatically."), infoColor: 0x9098A8); - } - else + if (_steamFriendLobbies.Count > 0) { foreach (var friendLobby in _steamFriendLobbies) { @@ -861,28 +872,24 @@ private static void ShowJoinTransportMenu(TitleScreen screen) ? Localize("Steam friend") : friendLobby.PersonaName.Trim(); - AddMenuButton( - screen, + UiButton( $"{displayName} - {Localize("Join")}", () => { _steamFriendJoinPageActive = false; HandleSteamFriendLobbyJoinRequest(capturedLobbyId, capturedHostSteamId); }, - Localize("Join this friend's Steam lobby")); + Localize("Join this friend's Steam lobby"), + textColor: 0x59D5FF); } } - AddMenuButton( - screen, + UiButton( Localize("Open Steam friends"), () => OpenSteamFriendsJoinOverlay(screen), Localize("Open Steam friends and choose Join Game")); - // Keep the code route as a compatibility fallback, but it is no longer the primary - // Steam flow. Friends hosting the mod appear above automatically. - AddMenuButton( - screen, + UiButton( Localize("Join by Steam lobby code (fallback)"), () => { @@ -891,8 +898,7 @@ private static void ShowJoinTransportMenu(TitleScreen screen) }, Localize("Connect by Steam lobby id/code from clipboard")); - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("Back"), () => { @@ -902,12 +908,7 @@ private static void ShowJoinTransportMenu(TitleScreen screen) GetText.Instance.GetString("Back to multiplayer menu")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst( - screen, - GetText.Instance.GetString("Lan join"), - Localize("Open Steam friends"), - Localize("Join by Steam lobby code (fallback)"), - GetText.Instance.GetString("Back")); + UiCommit(); RequestSteamFriendLobbyRefresh(force: _steamFriendLobbies.Count == 0); } @@ -922,7 +923,7 @@ private static void ShowJoinTransportMenu(TitleScreen screen) } } - private static void RequestSteamFriendLobbyRefresh(bool force) + internal static void RequestSteamFriendLobbyRefresh(bool force) { if (!_steamFriendJoinPageActive || _steamFriendLobbyRefreshInFlight) return; @@ -982,7 +983,7 @@ internal static void TickSteamFriendLobbyRefresh() /// code or spin up the legacy resolver worker. The transport handshake still validates /// protocol/build compatibility before gameplay state is accepted. /// - private static void HandleSteamFriendLobbyJoinRequest(ulong lobbyId, ulong hostSteamId) + internal static void HandleSteamFriendLobbyJoinRequest(ulong lobbyId, ulong hostSteamId) { var screen = GetTitleScreen(); if (screen == null || lobbyId == 0UL) @@ -1026,7 +1027,7 @@ private static void HandleSteamFriendLobbyJoinRequest(ulong lobbyId, ulong hostS ApplySteamJoinResult(screen, true, join, fromOverlay: true); } - private static void ShowConnectionMenu(TitleScreen screen, NetRole role) + internal static void ShowConnectionMenu(TitleScreen screen, NetRole role) { _menuSelection = role; _menuTransport = ConnectionTransport.Lan; @@ -1039,13 +1040,24 @@ private static void ShowConnectionMenu(TitleScreen screen, NetRole role) SetIsMainMenu(screen, false); screen.clearMenu(); - AddMenuButton( - screen, + UiBegin(); + if (role == NetRole.Host) + { + UiInfo(Localize("LAN host setup"), 0xF7FC65); + UiInfo(Localize("Share your IP and port with players on the same network."), 0x9098A8); + } + else + { + UiInfo(Localize("LAN join setup"), 0xF7FC65); + UiInfo(Localize("Enter the host IP and port, then Join."), 0x9098A8); + } + UiButton( $"{GetText.Instance.GetString("Username: ")}{_username}", () => EditUsername(screen), - GetText.Instance.GetString("Edit display name")); + GetText.Instance.GetString("Edit display name"), + fieldStyle: true); - AddMenuButton(screen, $"{GetText.Instance.GetString("IP: ")}{_mpIp}", () => + UiButton($"{GetText.Instance.GetString("IP: ")}{_mpIp}", () => { OpenTextInput(screen, GetText.Instance.GetString("IP address"), _mpIp, value => { @@ -1053,9 +1065,9 @@ private static void ShowConnectionMenu(TitleScreen screen, NetRole role) SaveConfig(); ShowConnectionMenu(screen, role); }, noSpaces: true); - }, GetText.Instance.GetString("Edit IP")); + }, GetText.Instance.GetString("Edit IP"), fieldStyle: true); - AddMenuButton(screen, $"{GetText.Instance.GetString("Port: ")}{_mpPort}", () => + UiButton($"{GetText.Instance.GetString("Port: ")}{_mpPort}", () => { OpenTextInput(screen, GetText.Instance.GetString("Port"), _mpPort.ToString(), value => { @@ -1065,54 +1077,54 @@ private static void ShowConnectionMenu(TitleScreen screen, NetRole role) SaveConfig(); ShowConnectionMenu(screen, role); }, noSpaces: true); - }, GetText.Instance.GetString("Edit port")); + }, GetText.Instance.GetString("Edit port"), fieldStyle: true); var actionLabel = role == NetRole.Host ? GetText.Instance.GetString("Host") : GetText.Instance.GetString("Join"); if (role == NetRole.Host) { - AddMenuButton(screen, actionLabel, () => + UiButton(actionLabel, () => { StartHostServerOnly(); ShowHostStatusMenu(screen); screen.ShouldAutoHideConnectionUI(true); - }, GetText.Instance.GetString("Start hosting")); + }, GetText.Instance.GetString("Start hosting"), textColor: 0x59D5FF); } else { - AddMenuButton(screen, actionLabel, () => + UiButton(actionLabel, () => { StartNetwork(role, screen); ShowClientWaitingMenu(screen); screen.ShouldAutoHideConnectionUI(true); - }, GetText.Instance.GetString("Connect to host")); + }, GetText.Instance.GetString("Connect to host"), textColor: 0x59D5FF); } - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("Back"), () => { + // Previous screen is the transport picker (LAN / Steam), not the hub. + // Do NOT call ShouldAutoHideConnectionUI(false): that sets ConnectionUI + // invisible and makes it look like the menu "just closed". if (role == NetRole.Host) ShowHostTransportMenu(screen); else ShowJoinTransportMenu(screen); - screen.ShouldAutoHideConnectionUI(false); + screen.ShouldAutoHideConnectionUI(true); }, - GetText.Instance.GetString("Back to multiplayer menu")); + role == NetRole.Host + ? GetText.Instance.GetString("Back to hosting options") + : GetText.Instance.GetString("Back to join options")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst( - screen, - GetText.Instance.GetString("Host game"), - GetText.Instance.GetString("Join game"), - "About Core Modding"); _inHostStatusMenu = false; _inClientWaitingMenu = false; if (role == NetRole.Host) { SetRole(NetRole.None); } + UiCommit(); } catch (Exception ex) { @@ -1125,7 +1137,7 @@ private static void ShowConnectionMenu(TitleScreen screen, NetRole role) } } - private static void StartSteamHost( + internal static void StartSteamHost( TitleScreen screen, SteamConnect.SteamLobbyVisibility visibility) { @@ -1183,7 +1195,7 @@ private static void StartSteamHost( screen.ShouldAutoHideConnectionUI(true); } - private static void OpenSteamFriendsJoinOverlay(TitleScreen screen) + internal static void OpenSteamFriendsJoinOverlay(TitleScreen screen) { if (SteamConnect.TryOpenFriendsOverlay(out var error)) { @@ -1199,7 +1211,7 @@ private static void OpenSteamFriendsJoinOverlay(TitleScreen screen) () => ShowJoinTransportMenu(screen)); } - private static void StartSteamJoin(TitleScreen screen) + internal static void StartSteamJoin(TitleScreen screen) { _menuSelection = NetRole.Client; _menuTransport = ConnectionTransport.Steam; @@ -1213,7 +1225,7 @@ private static void StartSteamJoin(TitleScreen screen) _ = Task.Run(() => { var ok = SteamConnect.TryResolveJoinEndpointFromClipboard(out var join); - EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: false)); + MainThreadPump.EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: false)); }); } @@ -1242,11 +1254,11 @@ internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) { _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId})", lobbyId); var ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out var join); - EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: true)); + MainThreadPump.EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: true)); }); } - private static void ApplySteamPersonaUsername(string? preferredPersona = null) + internal static void ApplySteamPersonaUsername(string? preferredPersona = null) { var candidate = string.IsNullOrWhiteSpace(preferredPersona) ? GetDefaultUsername() @@ -1262,7 +1274,7 @@ private static void ApplySteamPersonaUsername(string? preferredPersona = null) } /// Clears title menu and shows ConnectionUI while the Steam lobby is resolved off-thread. - private static void PrepareSteamJoinConnectionUiOnly(TitleScreen screen) + internal static void PrepareSteamJoinConnectionUiOnly(TitleScreen screen) { var prevSuppress = _suppressAutoButton; _suppressAutoButton = true; @@ -1275,6 +1287,7 @@ private static void PrepareSteamJoinConnectionUiOnly(TitleScreen screen) _inClientWaitingMenu = false; _inHostStatusMenu = false; screen.ShouldAutoHideConnectionUI(true); + UiLobby(); ConnectionUI.NotifyConnectionsChanged(); } catch (Exception ex) @@ -1288,7 +1301,7 @@ private static void PrepareSteamJoinConnectionUiOnly(TitleScreen screen) } } - private static void ApplySteamJoinResult(TitleScreen screen, bool ok, SteamConnect.JoinLobbyResult join, bool fromOverlay) + internal static void ApplySteamJoinResult(TitleScreen screen, bool ok, SteamConnect.JoinLobbyResult join, bool fromOverlay) { _steamJoinLobbyResolvePending = false; @@ -1341,7 +1354,7 @@ private static void ApplySteamJoinResult(TitleScreen screen, bool ok, SteamConne screen.ShouldAutoHideConnectionUI(true); } - private static void ShowConnectionErrorPopup(TitleScreen screen, string title, string details, Action onOk) + internal static void ShowConnectionErrorPopup(TitleScreen screen, string title, string details, Action onOk) { var prevSuppress = _suppressAutoButton; _suppressAutoButton = true; @@ -1351,20 +1364,20 @@ private static void ShowConnectionErrorPopup(TitleScreen screen, string title, s SetIsMainMenu(screen, false); screen.clearMenu(); - AddInfoLine(screen, title, infoColor: 0xFF9090); + UiBegin(); + UiInfo(title, 0xFF9090); if (!string.IsNullOrWhiteSpace(details)) - AddInfoLine(screen, details, infoColor: 0xE0E0E0); + UiInfo(details, 0xE0E0E0); - AddMenuButton( - screen, + UiButton( GetText.Instance.GetString("OK"), onOk, GetText.Instance.GetString("Return to previous menu")); RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); - RemoveDuplicatesKeepFirst(screen, GetText.Instance.GetString("OK")); _inClientWaitingMenu = false; _inHostStatusMenu = false; + UiCommit(); } catch (Exception ex) { @@ -1377,7 +1390,7 @@ private static void ShowConnectionErrorPopup(TitleScreen screen, string title, s } } - private static void StartNetwork(NetRole role, TitleScreen screen) + internal static void StartNetwork(NetRole role, TitleScreen screen) { try { @@ -1434,7 +1447,7 @@ private static void StartNetwork(NetRole role, TitleScreen screen) } } - private static void StartHostServerOnly(bool bindAnyAddress = false) + internal static void StartHostServerOnly(bool bindAnyAddress = false) { try { @@ -1470,7 +1483,7 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) } } - // private static void GameDisposeHook(Hook_Game.orig_onDispose orig, Game self) + // internal static void GameDisposeHook(Hook_Game.orig_onDispose orig, Game self) // { // try // { @@ -1484,7 +1497,7 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) // orig(self); // } - private static void HandleWorldExit(bool isDisposeHook = false) + internal static void HandleWorldExit(bool isDisposeHook = false) { ResetHostDisconnectCountdown(); lock (Sync) diff --git a/UI/GameMenu.MainThread.cs b/UI/ConnectionUI/MainThreadPump.cs similarity index 93% rename from UI/GameMenu.MainThread.cs rename to UI/ConnectionUI/MainThreadPump.cs index 51922b9..0300222 100644 --- a/UI/GameMenu.MainThread.cs +++ b/UI/ConnectionUI/MainThreadPump.cs @@ -10,7 +10,7 @@ namespace DeadCellsMultiplayerMod; /// /// Unified main-thread pump: Critical coalesce, then Network Channel, then Normal UI queue. /// -internal static partial class GameMenu +internal static class MainThreadPump { private readonly struct MainThreadWorkItem { @@ -56,7 +56,7 @@ public MainThreadWorkItem(string coalesceKey) private const int MainThreadQueueBurstBacklogThreshold = 96; private static long _lastMainThreadCoalescedDropLogTicks; - private static void ResetMainThreadQueuesLocked() + internal static void ResetMainThreadQueuesLocked() { while (_networkMainThreadQueue.Reader.TryRead(out _)) { } while (_criticalCoalescedKeys.TryDequeue(out _)) { } @@ -151,7 +151,7 @@ private static void LogCriticalMainThreadCoalescedDropRateLimited(string key) if (Interlocked.CompareExchange(ref _lastMainThreadCoalescedDropLogTicks, now, previous) != previous) return; - _log?.Warning( + LobbySession.Log?.Warning( "[NetMod] Rejected critical coalesced main-thread work because its queue is full (key={Key})", key); } @@ -204,7 +204,7 @@ private static int DrainCriticalAndNetworkMainThreadQueues(int budget) } catch (Exception ex) { - _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); + LobbySession.Log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); } } @@ -227,11 +227,11 @@ internal static void ProcessMainThreadQueue() // thread so no Steam API call can outlive the title screen or process shutdown. try { - TickSteamFriendLobbyRefresh(); + LobbySession.TickSteamFriendLobbyRefresh(); } catch (Exception ex) { - _log?.Debug("[NetMod][Steam] Friend lobby refresh tick failed: {Message}", ex.Message); + LobbySession.Log?.Debug("[NetMod][Steam] Friend lobby refresh tick failed: {Message}", ex.Message); } // Critical + reliable network protocol work must drain even when the UI queue is busy. @@ -263,7 +263,7 @@ internal static void ProcessMainThreadQueue() } catch (Exception ex) { - _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); + LobbySession.Log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); } finally { @@ -282,8 +282,8 @@ internal static void ProcessMainThreadQueue() slowActions++; actionLabel ??= DescribeMainThreadAction(action); RuntimeHitchWatch.LogSlow( - _log, - $"GameMenu.MainThreadQueueAction:{actionLabel}", + LobbySession.Log, + $"LobbySession.MainThreadQueueAction:{actionLabel}", actionMs, string.Create( CultureInfo.InvariantCulture, @@ -304,8 +304,8 @@ internal static void ProcessMainThreadQueue() if (perfEnabled && observedDepth >= RuntimeHitchWatch.MainThreadQueueDepthThreshold) { RuntimeHitchWatch.LogCount( - _log, - "GameMenu.MainThreadQueueDepth", + LobbySession.Log, + "LobbySession.MainThreadQueueDepth", observedDepth, RuntimeHitchWatch.MainThreadQueueDepthThreshold, string.Create(CultureInfo.InvariantCulture, $"processed={processed} remaining={remainingDepth}")); @@ -314,8 +314,8 @@ internal static void ProcessMainThreadQueue() if (actionsMs >= RuntimeHitchWatch.MainThreadQueueActionsSlowThresholdMs) { RuntimeHitchWatch.LogSlow( - _log, - "GameMenu.ExecuteMainThreadActions", + LobbySession.Log, + "LobbySession.ExecuteMainThreadActions", actionsMs, string.Create( CultureInfo.InvariantCulture, @@ -326,8 +326,8 @@ internal static void ProcessMainThreadQueue() if (hitchMs >= RuntimeHitchWatch.MainThreadQueueSlowThresholdMs) { RuntimeHitchWatch.LogSlow( - _log, - "GameMenu.ProcessMainThreadQueue", + LobbySession.Log, + "MainThreadPump.ProcessMainThreadQueue", hitchMs, string.Create(CultureInfo.InvariantCulture, $"processed={processed} startDepth={startDepth} remaining={remainingDepth}")); } diff --git a/UI/ConnectionUI/MultiplayerSaves.Api.cs b/UI/ConnectionUI/MultiplayerSaves.Api.cs new file mode 100644 index 0000000..b17a582 --- /dev/null +++ b/UI/ConnectionUI/MultiplayerSaves.Api.cs @@ -0,0 +1,8 @@ +namespace DeadCellsMultiplayerMod; + +/// Multiplayer save-slot API surface (implementation lives in LobbySession MultiplayerSaves partial). +internal static class MultiplayerSaves +{ + internal static void InitializeMultiplayerSaveHooks() + => LobbySession.InitializeMultiplayerSaveHooks(); +} diff --git a/UI/GameMenu.MultiplayerSaveSlots.cs b/UI/ConnectionUI/MultiplayerSaves.cs similarity index 83% rename from UI/GameMenu.MultiplayerSaveSlots.cs rename to UI/ConnectionUI/MultiplayerSaves.cs index 9e37b87..f5a4b8e 100644 --- a/UI/GameMenu.MultiplayerSaveSlots.cs +++ b/UI/ConnectionUI/MultiplayerSaves.cs @@ -11,34 +11,34 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { - private const string MultiplayerSaveFolderName = "MSave"; - private const string SavedGamesTitleLocalizationKey = "SAUVEGARDES"; - private const int CopyActionCode = 20; - private const int LiteralKeyboardXKeyCode = 88; + internal const string MultiplayerSaveFolderName = "MSave"; + internal const string SavedGamesTitleLocalizationKey = "SAUVEGARDES"; + internal const int CopyActionCode = 20; + internal const int LiteralKeyboardXKeyCode = 88; - private enum MultiplayerSaveMenuKind + internal enum MultiplayerSaveMenuKind { None, MultiplayerSlots, OriginalSourceSelection } - private static bool _multiplayerSaveHooksAttached; - private static bool _multiplayerSaveMenuOpening; - private static MultiplayerSaveMenuKind _multiplayerSaveMenuKind = MultiplayerSaveMenuKind.None; - private static NetRole _multiplayerSaveMenuReturnRole = NetRole.None; - private static int? _multiplayerSaveImportTargetSlot; - private static int? _preferredMultiplayerSaveSlot; - private static bool _forceMultiplayerSaveStore; - private static ControlLabel? _multiplayerSaveImportControlLabel; - private static string _multiplayerSaveDefaultTitle = string.Empty; - private static bool _hasCapturedMultiplayerSaveDefaultTitle; - private static bool _pendingSaveChoiceReflow; - private static MultiplayerSaveMenuKind _pendingSaveChoiceReflowKind = MultiplayerSaveMenuKind.None; + internal static bool _multiplayerSaveHooksAttached; + internal static bool _multiplayerSaveMenuOpening; + internal static MultiplayerSaveMenuKind _multiplayerSaveMenuKind = MultiplayerSaveMenuKind.None; + internal static NetRole _multiplayerSaveMenuReturnRole = NetRole.None; + internal static int? _multiplayerSaveImportTargetSlot; + internal static int? _preferredMultiplayerSaveSlot; + internal static bool _forceMultiplayerSaveStore; + internal static ControlLabel? _multiplayerSaveImportControlLabel; + internal static string _multiplayerSaveDefaultTitle = string.Empty; + internal static bool _hasCapturedMultiplayerSaveDefaultTitle; + internal static bool _pendingSaveChoiceReflow; + internal static MultiplayerSaveMenuKind _pendingSaveChoiceReflowKind = MultiplayerSaveMenuKind.None; - private static void InitializeMultiplayerSaveHooks() + internal static void InitializeMultiplayerSaveHooks() { if (_multiplayerSaveHooksAttached) return; @@ -56,12 +56,12 @@ private static void InitializeMultiplayerSaveHooks() _multiplayerSaveHooksAttached = true; } - private static string GetMultiplayerSaveButtonLabel() + internal static string GetMultiplayerSaveButtonLabel() { return FormatLocalized("Save: Slot {0}", ResolveSaveSlotNumber(null) + 1); } - private static void OpenMultiplayerSlotMenu(TitleScreen screen) + internal static void OpenMultiplayerSlotMenu(TitleScreen screen) { _multiplayerSaveMenuReturnRole = _inHostStatusMenu ? NetRole.Host @@ -72,12 +72,12 @@ private static void OpenMultiplayerSlotMenu(TitleScreen screen) OpenSaveMenu(screen, MultiplayerSaveMenuKind.MultiplayerSlots); } - private static void OpenOriginalSaveImportMenu(TitleScreen screen) + internal static void OpenOriginalSaveImportMenu(TitleScreen screen) { OpenSaveMenu(screen, MultiplayerSaveMenuKind.OriginalSourceSelection); } - private static void OpenSaveMenu(TitleScreen screen, MultiplayerSaveMenuKind kind) + internal static void OpenSaveMenu(TitleScreen screen, MultiplayerSaveMenuKind kind) { _multiplayerSaveMenuKind = kind; _multiplayerSaveMenuOpening = true; @@ -99,7 +99,7 @@ private static void OpenSaveMenu(TitleScreen screen, MultiplayerSaveMenuKind kin } } - private static void Hook_TitleScreen_onLeavingSaveMenu(Hook_TitleScreen.orig_onLeavingSaveMenu orig, TitleScreen self) + internal static void Hook_TitleScreen_onLeavingSaveMenu(Hook_TitleScreen.orig_onLeavingSaveMenu orig, TitleScreen self) { var returnRole = _multiplayerSaveMenuReturnRole; @@ -126,7 +126,7 @@ private static void Hook_TitleScreen_onLeavingSaveMenu(Hook_TitleScreen.orig_onL } } - private static void Hook__SaveChoice___constructor__(Hook__SaveChoice.orig___constructor__ orig, SaveChoice self, TitleScreen tween) + internal static void Hook__SaveChoice___constructor__(Hook__SaveChoice.orig___constructor__ orig, SaveChoice self, TitleScreen tween) { orig(self, tween); @@ -151,7 +151,7 @@ private static void Hook__SaveChoice___constructor__(Hook__SaveChoice.orig___con } } - private static void Hook_SaveChoice_onCopy(Hook_SaveChoice.orig_onCopy orig, SaveChoice self) + internal static void Hook_SaveChoice_onCopy(Hook_SaveChoice.orig_onCopy orig, SaveChoice self) { if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.OriginalSourceSelection) return; @@ -166,7 +166,7 @@ private static void Hook_SaveChoice_onCopy(Hook_SaveChoice.orig_onCopy orig, Sav return; } - private static void Hook_SaveChoice_onValidate(Hook_SaveChoice.orig_onValidate orig, SaveChoice self) + internal static void Hook_SaveChoice_onValidate(Hook_SaveChoice.orig_onValidate orig, SaveChoice self) { if (_multiplayerSaveMenuKind != MultiplayerSaveMenuKind.OriginalSourceSelection) { @@ -201,7 +201,7 @@ private static void Hook_SaveChoice_onValidate(Hook_SaveChoice.orig_onValidate o SwitchSaveChoiceStore(self, MultiplayerSaveMenuKind.MultiplayerSlots); } - private static void Hook_SaveChoice_onCancel(Hook_SaveChoice.orig_onCancel orig, SaveChoice self) + internal static void Hook_SaveChoice_onCancel(Hook_SaveChoice.orig_onCancel orig, SaveChoice self) { if (_multiplayerSaveMenuKind != MultiplayerSaveMenuKind.OriginalSourceSelection) { @@ -213,7 +213,7 @@ private static void Hook_SaveChoice_onCancel(Hook_SaveChoice.orig_onCancel orig, SwitchSaveChoiceStore(self, MultiplayerSaveMenuKind.MultiplayerSlots); } - private static void Hook_SaveChoice_onDelete(Hook_SaveChoice.orig_onDelete orig, SaveChoice self) + internal static void Hook_SaveChoice_onDelete(Hook_SaveChoice.orig_onDelete orig, SaveChoice self) { if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.OriginalSourceSelection) return; @@ -234,7 +234,7 @@ private static void Hook_SaveChoice_onDelete(Hook_SaveChoice.orig_onDelete orig, } } - private static void Hook_SaveChoice_onDispose(Hook_SaveChoice.orig_onDispose orig, SaveChoice self) + internal static void Hook_SaveChoice_onDispose(Hook_SaveChoice.orig_onDispose orig, SaveChoice self) { try { @@ -249,7 +249,7 @@ private static void Hook_SaveChoice_onDispose(Hook_SaveChoice.orig_onDispose ori } } - private static void Hook_SaveChoice_update(Hook_SaveChoice.orig_update orig, SaveChoice self) + internal static void Hook_SaveChoice_update(Hook_SaveChoice.orig_update orig, SaveChoice self) { TryFlushPendingSaveChoiceReflow(self); EnsureCurrentSaveChoiceTitle(self); @@ -270,7 +270,7 @@ private static void Hook_SaveChoice_update(Hook_SaveChoice.orig_update orig, Sav orig(self); } - private static dc.String Hook__Save_fileName(Hook__Save.orig_fileName orig, int? slot) + internal static dc.String Hook__Save_fileName(Hook__Save.orig_fileName orig, int? slot) { if (!ShouldUseMultiplayerSaveStore()) return orig(slot); @@ -287,7 +287,7 @@ private static dc.String Hook__Save_fileName(Hook__Save.orig_fileName orig, int? } } - private static bool ShouldUseMultiplayerSaveStore() + internal static bool ShouldUseMultiplayerSaveStore() { if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.OriginalSourceSelection) return false; @@ -295,7 +295,7 @@ private static bool ShouldUseMultiplayerSaveStore() return _forceMultiplayerSaveStore || _role != NetRole.None || _multiplayerSaveMenuKind == MultiplayerSaveMenuKind.MultiplayerSlots || _multiplayerSaveMenuOpening; } - private static int ResolveSaveSlotNumber(int? slot) + internal static int ResolveSaveSlotNumber(int? slot) { if (slot.HasValue && slot.Value >= 0) return slot.Value; @@ -313,7 +313,7 @@ private static int ResolveSaveSlotNumber(int? slot) return 0; } - private static string GetSaveRootPath() + internal static string GetSaveRootPath() { try { @@ -335,17 +335,17 @@ private static string GetSaveRootPath() } } - private static string GetOriginalSaveRelativeFilePath(int? slot) + internal static string GetOriginalSaveRelativeFilePath(int? slot) { return $"user_{ResolveSaveSlotNumber(slot)}.dat"; } - private static string GetMultiplayerSaveRelativeFilePath(int? slot) + internal static string GetMultiplayerSaveRelativeFilePath(int? slot) { return $"{MultiplayerSaveFolderName}/user_{ResolveSaveSlotNumber(slot)}.dat"; } - private static string GetAbsoluteSavePath(string relativePath) + internal static string GetAbsoluteSavePath(string relativePath) { var normalized = relativePath .Replace('/', IOPath.DirectorySeparatorChar) @@ -354,12 +354,12 @@ private static string GetAbsoluteSavePath(string relativePath) return IOPath.GetFullPath(IOPath.Combine(GetSaveRootPath(), normalized)); } - private static void EnsureMultiplayerSaveFolderExists() + internal static void EnsureMultiplayerSaveFolderExists() { IODirectory.CreateDirectory(GetAbsoluteSavePath(MultiplayerSaveFolderName)); } - private static void ConfigureMultiplayerSaveChoice(SaveChoice self) + internal static void ConfigureMultiplayerSaveChoice(SaveChoice self) { if (self == null) return; @@ -373,7 +373,7 @@ private static void ConfigureMultiplayerSaveChoice(SaveChoice self) self.fControlLabel?.reflow(); } - private static void ConfigureOriginalSourceSaveChoice(SaveChoice self) + internal static void ConfigureOriginalSourceSaveChoice(SaveChoice self) { if (self == null) return; @@ -385,7 +385,7 @@ private static void ConfigureOriginalSourceSaveChoice(SaveChoice self) self.fControlLabel?.reflow(); } - private static void SwitchSaveChoiceStore(SaveChoice self, MultiplayerSaveMenuKind kind) + internal static void SwitchSaveChoiceStore(SaveChoice self, MultiplayerSaveMenuKind kind) { if (self == null) return; @@ -415,7 +415,7 @@ private static void SwitchSaveChoiceStore(SaveChoice self, MultiplayerSaveMenuKi } } - private static void TryFlushPendingSaveChoiceReflow(SaveChoice self) + internal static void TryFlushPendingSaveChoiceReflow(SaveChoice self) { if (!_pendingSaveChoiceReflow || self == null) return; @@ -430,7 +430,7 @@ private static void TryFlushPendingSaveChoiceReflow(SaveChoice self) TryRebuildSaveChoice(self, _multiplayerSaveMenuKind); } - private static void TryRebuildSaveChoice(SaveChoice self, MultiplayerSaveMenuKind kind) + internal static void TryRebuildSaveChoice(SaveChoice self, MultiplayerSaveMenuKind kind) { try { @@ -445,7 +445,7 @@ private static void TryRebuildSaveChoice(SaveChoice self, MultiplayerSaveMenuKin } } - private static void AttachSaveChoiceActionBridge(SaveChoice self) + internal static void AttachSaveChoiceActionBridge(SaveChoice self) { var controller = self?.controller; if (controller == null) @@ -460,7 +460,7 @@ private static void AttachSaveChoiceActionBridge(SaveChoice self) }); } - private static void HandleSaveChoiceActionPressed(SaveChoice self, int act) + internal static void HandleSaveChoiceActionPressed(SaveChoice self, int act) { if (act != CopyActionCode) return; @@ -477,7 +477,7 @@ private static void HandleSaveChoiceActionPressed(SaveChoice self, int act) } } - private static bool TryBeginMultiplayerSaveImportSelection(SaveChoice self) + internal static bool TryBeginMultiplayerSaveImportSelection(SaveChoice self) { if (_multiplayerSaveMenuKind != MultiplayerSaveMenuKind.MultiplayerSlots) return false; @@ -492,7 +492,7 @@ private static bool TryBeginMultiplayerSaveImportSelection(SaveChoice self) return true; } - private static bool TryResolveImportTargetSlot(SaveChoice self, out int slot) + internal static bool TryResolveImportTargetSlot(SaveChoice self, out int slot) { slot = 0; if (TryGetSelectedSaveSlot(self, out slot)) @@ -521,7 +521,7 @@ private static bool TryResolveImportTargetSlot(SaveChoice self, out int slot) return false; } - private static void TryCaptureDefaultSaveTitle(SaveChoice self) + internal static void TryCaptureDefaultSaveTitle(SaveChoice self) { if (_hasCapturedMultiplayerSaveDefaultTitle) return; @@ -538,7 +538,7 @@ private static void TryCaptureDefaultSaveTitle(SaveChoice self) _hasCapturedMultiplayerSaveDefaultTitle = true; } - private static string ResolveSavedGamesTitle() + internal static string ResolveSavedGamesTitle() { try { @@ -559,7 +559,7 @@ private static string ResolveSavedGamesTitle() : _multiplayerSaveDefaultTitle; } - private static void EnsureCurrentSaveChoiceTitle(SaveChoice self) + internal static void EnsureCurrentSaveChoiceTitle(SaveChoice self) { var title = self?.title; if (title == null) @@ -584,14 +584,14 @@ private static void EnsureCurrentSaveChoiceTitle(SaveChoice self) } } - private static bool IsBenignSaveRebuildException(Exception ex) + internal static bool IsBenignSaveRebuildException(Exception ex) { var message = ex?.Message; return !string.IsNullOrEmpty(message) && message.IndexOf("Null access ._getCdObject", StringComparison.OrdinalIgnoreCase) >= 0; } - private static bool IsActionPressed(ControllerAccess? controllerAccess, int actionCode) + internal static bool IsActionPressed(ControllerAccess? controllerAccess, int actionCode) { if (controllerAccess == null) return false; @@ -616,12 +616,12 @@ private static bool IsActionPressed(ControllerAccess? controllerAccess, int acti IsPressed(controller, controller.get_bindings().third, actionCode, isGamepad: false); } - private static bool IsLiteralXPressed() + internal static bool IsLiteralXPressed() { return Key.Class.isPressed.Invoke(LiteralKeyboardXKeyCode); } - private static bool IsPressed(Controller controller, ArrayBytes_Int? bindings, int actionCode, bool isGamepad) + internal static bool IsPressed(Controller controller, ArrayBytes_Int? bindings, int actionCode, bool isGamepad) { var keyCode = GetBinding(bindings, actionCode); if (keyCode < 0) @@ -633,7 +633,7 @@ private static bool IsPressed(Controller controller, ArrayBytes_Int? bindings, i return (controller.mode & Controller.Class.ENABLE_KEY) != 0 && Key.Class.isPressed.Invoke(keyCode); } - private static int GetBinding(ArrayBytes_Int? bindings, int actionCode) + internal static int GetBinding(ArrayBytes_Int? bindings, int actionCode) { if (bindings == null) return -1; @@ -643,12 +643,12 @@ private static int GetBinding(ArrayBytes_Int? bindings, int actionCode) return Marshal.ReadInt32(bindings.bytes, actionCode << 2); } - private static double GetCurrentUnixTimeSeconds() + internal static double GetCurrentUnixTimeSeconds() { return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; } - private static void EnsureValidSaveChoiceSelection(SaveChoice self) + internal static void EnsureValidSaveChoiceSelection(SaveChoice self) { if (self == null) return; @@ -681,7 +681,7 @@ private static void EnsureValidSaveChoiceSelection(SaveChoice self) } } - private static void EnsureImportControlLabel(SaveChoice self) + internal static void EnsureImportControlLabel(SaveChoice self) { if (self?.fControlLabel == null) return; @@ -709,7 +709,7 @@ private static void EnsureImportControlLabel(SaveChoice self) self.fControlLabel.reflow(); } - private static ControlLabel? FindImportControlLabel(SaveChoice self) + internal static ControlLabel? FindImportControlLabel(SaveChoice self) { var children = self?.fControlLabel?.children; if (children == null) @@ -728,7 +728,7 @@ private static void EnsureImportControlLabel(SaveChoice self) return null; } - private static void RemoveDuplicateImportControlLabels(SaveChoice self, ControlLabel keep) + internal static void RemoveDuplicateImportControlLabels(SaveChoice self, ControlLabel keep) { var controlParent = self?.fControlLabel; var children = controlParent?.children; @@ -748,7 +748,7 @@ private static void RemoveDuplicateImportControlLabels(SaveChoice self, ControlL } } - private static void SetControlLabelVisible(SaveChoice self, int index, bool visible) + internal static void SetControlLabelVisible(SaveChoice self, int index, bool visible) { var controlLabel = GetControlLabel(self, index); if (controlLabel == null) @@ -758,7 +758,7 @@ private static void SetControlLabelVisible(SaveChoice self, int index, bool visi controlLabel.reflow(); } - private static ControlLabel? GetControlLabel(SaveChoice self, int index) + internal static ControlLabel? GetControlLabel(SaveChoice self, int index) { var children = self?.fControlLabel?.children; if (children == null || index < 0 || index >= children.length) @@ -767,7 +767,7 @@ private static void SetControlLabelVisible(SaveChoice self, int index, bool visi return children.array[index] as ControlLabel; } - private static ArrayBytes_Int CreateActionArray(int actionCode) + internal static ArrayBytes_Int CreateActionArray(int actionCode) { var values = new ArrayBytes_Int(); try @@ -782,7 +782,7 @@ private static ArrayBytes_Int CreateActionArray(int actionCode) return values; } - private static void TrySelectPreferredMultiplayerSlot(SaveChoice self) + internal static void TrySelectPreferredMultiplayerSlot(SaveChoice self) { if (self == null || !_preferredMultiplayerSaveSlot.HasValue) return; @@ -828,7 +828,7 @@ private static void TrySelectPreferredMultiplayerSlot(SaveChoice self) } } - private static bool TryGetSelectedSaveWindow(SaveChoice self, out SaveWindow? window) + internal static bool TryGetSelectedSaveWindow(SaveChoice self, out SaveWindow? window) { window = null; if (self == null) @@ -845,7 +845,7 @@ private static bool TryGetSelectedSaveWindow(SaveChoice self, out SaveWindow? wi return window != null; } - private static bool TryGetSelectedSaveSlot(SaveChoice self, out int slot) + internal static bool TryGetSelectedSaveSlot(SaveChoice self, out int slot) { slot = 0; if (TryGetSelectedSaveWindow(self, out var window) && window?.si != null) @@ -857,7 +857,7 @@ private static bool TryGetSelectedSaveSlot(SaveChoice self, out int slot) return TryGetSelectedSaveIndex(self, out slot); } - private static bool TryGetSelectedSourceSaveSlot(SaveChoice self, out int slot) + internal static bool TryGetSelectedSourceSaveSlot(SaveChoice self, out int slot) { slot = 0; if (TryGetSelectedSaveWindow(self, out var window) && window?.si != null) @@ -875,7 +875,7 @@ private static bool TryGetSelectedSourceSaveSlot(SaveChoice self, out int slot) return dc.tool.File.Class.exists.Invoke(MakeHLString(GetOriginalSaveRelativeFilePath(slot))); } - private static bool TryGetSelectedSaveIndex(SaveChoice self, out int slot) + internal static bool TryGetSelectedSaveIndex(SaveChoice self, out int slot) { slot = 0; if (self == null) @@ -892,7 +892,7 @@ private static bool TryGetSelectedSaveIndex(SaveChoice self, out int slot) return true; } - private static bool CopyOriginalSaveIntoMultiplayerSlot(int sourceSlot, int targetSlot) + internal static bool CopyOriginalSaveIntoMultiplayerSlot(int sourceSlot, int targetSlot) { try { @@ -923,7 +923,7 @@ private static bool CopyOriginalSaveIntoMultiplayerSlot(int sourceSlot, int targ } } - private static void SetCurrentSaveSlot(int slot) + internal static void SetCurrentSaveSlot(int slot) { try { diff --git a/UI/ConnectionUI/ReadySync.Api.cs b/UI/ConnectionUI/ReadySync.Api.cs new file mode 100644 index 0000000..a8941ff --- /dev/null +++ b/UI/ConnectionUI/ReadySync.Api.cs @@ -0,0 +1,11 @@ +namespace DeadCellsMultiplayerMod; + +/// Lobby ready-state API surface (implementation lives in LobbySession ReadySync partial). +internal static class ReadySync +{ + internal static void ReceiveRemoteReady(int userId, bool ready) + => LobbySession.ReceiveRemoteReady(userId, ready); + + internal static void ResetLobbyReadyState() + => LobbySession.ResetLobbyReadyState(); +} diff --git a/UI/GameMenu.Ready.cs b/UI/ConnectionUI/ReadySync.cs similarity index 87% rename from UI/GameMenu.Ready.cs rename to UI/ConnectionUI/ReadySync.cs index 24c9841..98f5d9b 100644 --- a/UI/GameMenu.Ready.cs +++ b/UI/ConnectionUI/ReadySync.cs @@ -5,9 +5,9 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { - private static void ResetLobbyReadyState() + internal static void ResetLobbyReadyState() { lock (Sync) { @@ -15,13 +15,13 @@ private static void ResetLobbyReadyState() } } - private static void ResetLobbyReadyStateLocked() + internal static void ResetLobbyReadyStateLocked() { _localReady = false; _playersDisplay.Clear(); } - private static void ResetLobbyLaunchStateLocked() + internal static void ResetLobbyLaunchStateLocked() { StopHostRunLaunchBeacon("lobby_launch_state_reset"); _clientLevelGraphWaitStartedTicks = 0; @@ -46,7 +46,7 @@ private static void ResetLobbyLaunchStateLocked() ResetClientLaunchSessionLocked(); } - private static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState = false) + internal static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState = false) { lock (Sync) { @@ -58,13 +58,13 @@ private static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState = } } - private static void ToggleLocalReadyFromMenu(TitleScreen screen) + internal static void ToggleLocalReadyFromMenu(TitleScreen screen) { SetLocalReady(!_localReady, sendToRemote: true, refreshMenu: true); screen.ShouldAutoHideConnectionUI(true); } - private static void SetLocalReady(bool ready, bool sendToRemote, bool refreshMenu) + internal static void SetLocalReady(bool ready, bool sendToRemote, bool refreshMenu) { if (_localReady == ready && !refreshMenu) return; @@ -76,7 +76,7 @@ private static void SetLocalReady(bool ready, bool sendToRemote, bool refreshMen RequestLobbyMenuRefresh(); } - private static void SendLocalReadyState() + internal static void SendLocalReadyState() { var net = NetRef; if (net == null || !net.IsAlive || net.id <= 0) @@ -100,9 +100,9 @@ internal static void ReceiveRemoteReady(int userId, bool ready) RequestLobbyMenuRefresh(); } - private static void RequestLobbyMenuRefresh() + internal static void RequestLobbyMenuRefresh() { - EnqueueMainThreadCoalesced("ui:lobby-ready-refresh", () => + MainThreadPump.EnqueueMainThreadCoalesced("ui:lobby-ready-refresh", () => { lock (Sync) { @@ -125,7 +125,7 @@ private static void RequestLobbyMenuRefresh() }); } - private static void RefreshPlayersDisplayFromNetwork() + internal static void RefreshPlayersDisplayFromNetwork() { _playersDisplay.Clear(); @@ -193,12 +193,12 @@ private static void RefreshPlayersDisplayFromNetwork() }); } - private static string GetReadyButtonLabel() + internal static string GetReadyButtonLabel() { return _localReady ? "Ready: On" : "Ready: Off"; } - private static string GetPendingLaunchSummaryLabel(TitleScreen? screen) + internal static string GetPendingLaunchSummaryLabel(TitleScreen? screen) { PendingLaunchAction action; bool custom; diff --git a/UI/ConnectionUI/ReviveInput.Api.cs b/UI/ConnectionUI/ReviveInput.Api.cs new file mode 100644 index 0000000..dbfc6d8 --- /dev/null +++ b/UI/ConnectionUI/ReviveInput.Api.cs @@ -0,0 +1,10 @@ +using dc.en; + +namespace DeadCellsMultiplayerMod; + +/// Revive-input API surface (implementation lives in LobbySession ReviveInput partial). +internal static class ReviveInput +{ + internal static bool IsReviveHoldInputDown(Hero? hero) + => LobbySession.IsReviveHoldInputDown(hero); +} diff --git a/UI/GameMenu.ReviveInput.cs b/UI/ConnectionUI/ReviveInput.cs similarity index 96% rename from UI/GameMenu.ReviveInput.cs rename to UI/ConnectionUI/ReviveInput.cs index d8d1982..e4fe1a2 100644 --- a/UI/GameMenu.ReviveInput.cs +++ b/UI/ConnectionUI/ReviveInput.cs @@ -8,9 +8,9 @@ namespace DeadCellsMultiplayerMod; -internal static partial class GameMenu +internal static partial class LobbySession { - private const int ReviveInteractKeyCode = 82; // R (keyboard) + internal const int ReviveInteractKeyCode = 82; // R (keyboard) /// Hold-to-revive: keyboard R plus gamepad face buttons / primary-secondary (same binding resolution as menus). internal static bool IsReviveHoldInputDown(Hero? hero) diff --git a/UI/ConnectionUI/RunLaunchFlow.Api.cs b/UI/ConnectionUI/RunLaunchFlow.Api.cs new file mode 100644 index 0000000..1104756 --- /dev/null +++ b/UI/ConnectionUI/RunLaunchFlow.Api.cs @@ -0,0 +1,50 @@ +namespace DeadCellsMultiplayerMod; + +/// Run-launch handshake API surface (implementation lives in LobbySession RunLaunchFlow partials). +internal static class RunLaunchFlow +{ + internal static void ReceiveRunLaunchCommitPayload(string payload) + => LobbySession.ReceiveRunLaunchCommitPayload(payload); + + internal static void ReceiveRunLaunchAckPayload(string payload) + => LobbySession.ReceiveRunLaunchAckPayload(payload); + + internal static void ReceiveRunLaunchExecutePayload(string payload) + => LobbySession.ReceiveRunLaunchExecutePayload(payload); + + internal static void ReceiveRunLaunchQueuedPayload(string payload) + => LobbySession.ReceiveRunLaunchQueuedPayload(payload); + + internal static void ReceiveRunLevelReadyPayload(string payload) + => LobbySession.ReceiveRunLevelReadyPayload(payload); + + internal static void ReceiveRunLaunchCancelPayload(string payload) + => LobbySession.ReceiveRunLaunchCancelPayload(payload); + + public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) + => LobbySession.ReceiveHostRunSeed(sequence, seed, launchKind); + + public static void ReceiveHostRunRestart(int seed) + => LobbySession.ReceiveHostRunRestart(seed); + + public static void ReceiveLaunchMode( + int actionValue, + bool launchCustom, + bool launchStreamEnabled, + bool newCoopWorldPrepared, + string? coopId, + bool hostHasContinueSave) + => LobbySession.ReceiveLaunchMode( + actionValue, + launchCustom, + launchStreamEnabled, + newCoopWorldPrepared, + coopId, + hostHasContinueSave); + + public static void ReceiveCustomGameData(string? payload) + => LobbySession.ReceiveCustomGameData(payload); + + internal static bool TryConsumeMidRunJoinSpawn() + => LobbySession.TryConsumeMidRunJoinSpawn(); +} diff --git a/UI/GameMenu.RunLaunchBeacon.cs b/UI/ConnectionUI/RunLaunchFlow.Beacon.cs similarity index 91% rename from UI/GameMenu.RunLaunchBeacon.cs rename to UI/ConnectionUI/RunLaunchFlow.Beacon.cs index f01930f..a7723da 100644 --- a/UI/GameMenu.RunLaunchBeacon.cs +++ b/UI/ConnectionUI/RunLaunchFlow.Beacon.cs @@ -23,19 +23,19 @@ namespace DeadCellsMultiplayerMod; /// It deliberately runs on its own task rather than from TickMenu: the window where this matters /// most is exactly when the host's main thread is busy generating the first level. /// -internal static partial class GameMenu +internal static partial class LobbySession { - private const int RunLaunchBeaconIntervalMs = 1200; + internal const int RunLaunchBeaconIntervalMs = 1200; /// /// Upper bound on how long the host keeps trying. Long enough to cover a slow client's whole /// load, short enough that an abandoned launch stops producing traffic and log noise. /// - private const int RunLaunchBeaconMaxDurationMs = 120_000; - private const int RunLaunchBeaconLogIntervalMs = 6000; + internal const int RunLaunchBeaconMaxDurationMs = 120_000; + internal const int RunLaunchBeaconLogIntervalMs = 6000; - private static readonly object RunLaunchBeaconSync = new(); - private static CancellationTokenSource? _runLaunchBeaconCts; - private static Task? _runLaunchBeaconTask; + internal static readonly object RunLaunchBeaconSync = new(); + internal static CancellationTokenSource? _runLaunchBeaconCts; + internal static Task? _runLaunchBeaconTask; /// /// Starts (or restarts) the beacon for the sequence the host just committed. Safe to call from @@ -68,7 +68,7 @@ internal static void StopHostRunLaunchBeacon(string reason) StopHostRunLaunchBeaconLocked(reason); } - private static void StopHostRunLaunchBeaconLocked(string reason) + internal static void StopHostRunLaunchBeaconLocked(string reason) { var cts = _runLaunchBeaconCts; _runLaunchBeaconCts = null; @@ -81,7 +81,7 @@ private static void StopHostRunLaunchBeaconLocked(string reason) _log?.Debug("[NetMod][RunLaunch] Launch beacon stopped ({Reason})", reason); } - private static async Task RunHostLaunchBeaconAsync(int sequence, CancellationToken ct) + internal static async Task RunHostLaunchBeaconAsync(int sequence, CancellationToken ct) { var deadline = Environment.TickCount64 + RunLaunchBeaconMaxDurationMs; var nextLogTick = 0L; diff --git a/UI/GameMenu.ClientLaunchSession.cs b/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs similarity index 86% rename from UI/GameMenu.ClientLaunchSession.cs rename to UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs index 03b994a..4855b40 100644 --- a/UI/GameMenu.ClientLaunchSession.cs +++ b/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs @@ -4,9 +4,9 @@ namespace DeadCellsMultiplayerMod; /// Single client auto-start arming path. Call sites signal progress; only /// sets lobby _pendingAutoStart. /// -internal static partial class GameMenu +internal static partial class LobbySession { - private enum ClientLaunchPhase + internal enum ClientLaunchPhase { Lobby, IntentReceived, @@ -17,19 +17,19 @@ private enum ClientLaunchPhase RestartPending } - private static ClientLaunchPhase _clientLaunchPhase = ClientLaunchPhase.Lobby; + internal static ClientLaunchPhase _clientLaunchPhase = ClientLaunchPhase.Lobby; - private static void ResetClientLaunchSessionLocked() + internal static void ResetClientLaunchSessionLocked() { _clientLaunchPhase = ClientLaunchPhase.Lobby; } - private static void MarkClientLaunchInRunLocked() + internal static void MarkClientLaunchInRunLocked() { _clientLaunchPhase = ClientLaunchPhase.InRun; } - private static void MarkClientLaunchRestartPendingLocked() + internal static void MarkClientLaunchRestartPendingLocked() { _clientLaunchPhase = ClientLaunchPhase.RestartPending; _pendingAutoStart = false; @@ -39,13 +39,13 @@ private static void MarkClientLaunchRestartPendingLocked() /// After gen/seed/commit/exec/custom-data/level-desc progress, recompute whether /// the client lobby auto-start may arm. /// - private static void SignalClientLaunchProgressLocked() + internal static void SignalClientLaunchProgressLocked() { ReevaluateClientLaunchArmLocked(); } /// - /// Network/main-thread entry for launch prereqs that arrive outside GameMenu + /// Network/main-thread entry for launch prereqs that arrive outside LobbySession /// (remote level graph, boss rune). Safe to call from receive paths. /// internal static void NotifyClientLaunchPrerequisiteProgress() @@ -57,7 +57,7 @@ internal static void NotifyClientLaunchPrerequisiteProgress() } } - private static void ReevaluateClientLaunchArmLocked() + internal static void ReevaluateClientLaunchArmLocked() { if (_role != NetRole.Client) { @@ -116,7 +116,7 @@ private static void ReevaluateClientLaunchArmLocked() /// /// TickMenu claim: Armed → Starting. Returns false if another pump already claimed. /// - private static bool TryClaimClientAutoStartLocked() + internal static bool TryClaimClientAutoStartLocked() { if (_role != NetRole.Client || _inActualRun || @@ -139,7 +139,7 @@ private static bool TryClaimClientAutoStartLocked() return true; } - private static void ReleaseClientAutoStartClaimLocked() + internal static void ReleaseClientAutoStartClaimLocked() { _autoStartTriggered = false; _pendingAutoStart = true; diff --git a/UI/GameMenu.RunLaunchCompat.cs b/UI/ConnectionUI/RunLaunchFlow.Compat.cs similarity index 84% rename from UI/GameMenu.RunLaunchCompat.cs rename to UI/ConnectionUI/RunLaunchFlow.Compat.cs index 012ff2c..a85b4d4 100644 --- a/UI/GameMenu.RunLaunchCompat.cs +++ b/UI/ConnectionUI/RunLaunchFlow.Compat.cs @@ -9,28 +9,28 @@ namespace DeadCellsMultiplayerMod; /// /// Sequenced seed / precommit / protocol-mismatch helpers for run launch. /// -internal static partial class GameMenu +internal static partial class LobbySession { - private static int _serverSeedSequence; - private static int _remoteSeedSequence; - private static int _consumedRemoteSeedSequence; - private static string _remoteLaunchKind = string.Empty; + internal static int _serverSeedSequence; + internal static int _remoteSeedSequence; + internal static int _consumedRemoteSeedSequence; + internal static string _remoteLaunchKind = string.Empty; - private const int RemoteRunSeedWaitMs = 2000; - private const int RunSeedTransitionGraceMs = 2000; + internal const int RemoteRunSeedWaitMs = 2000; + internal const int RunSeedTransitionGraceMs = 2000; - private static long _clientRestartPendingUntilTicks; - private const int ClientRestartPendingTtlMs = 12000; + internal static long _clientRestartPendingUntilTicks; + internal const int ClientRestartPendingTtlMs = 12000; - private static int? _precommittedHostSeed; - private static int _precommittedHostSeedSequence; - private static string _precommittedHostLaunchKind = string.Empty; - private static long _precommittedHostSeedExpiresAtTicks; - private const int PrecommittedHostSeedTtlMs = 300000; + internal static int? _precommittedHostSeed; + internal static int _precommittedHostSeedSequence; + internal static string _precommittedHostLaunchKind = string.Empty; + internal static long _precommittedHostSeedExpiresAtTicks; + internal const int PrecommittedHostSeedTtlMs = 300000; - private static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; + internal static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; - private static void ResetRunLaunchCompatStateLocked() + internal static void ResetRunLaunchCompatStateLocked() { _serverSeedSequence = 0; _remoteSeedSequence = 0; @@ -249,7 +249,7 @@ internal static void CancelPrecommittedHostRunSeed(string reason = "precommitted CancelHostStructuredLaunch(sequence, reason); } - private static void ClearPrecommittedHostRunSeedLocked() + internal static void ClearPrecommittedHostRunSeedLocked() { _precommittedHostSeed = null; _precommittedHostSeedSequence = 0; @@ -329,12 +329,12 @@ public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) ScheduleClientRunSeedReconcile(sequence, seed); } - private static void ScheduleClientRunSeedReconcile(int sequence, int seed) + internal static void ScheduleClientRunSeedReconcile(int sequence, int seed) { _ = Task.Run(async () => { await Task.Delay(RunSeedTransitionGraceMs).ConfigureAwait(false); - EnqueueCriticalMainThreadCoalesced("game:run-seed-reconcile", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("game:run-seed-reconcile", () => { var shouldRestart = false; lock (Sync) @@ -373,7 +373,7 @@ public static bool TryConsumeNextRemoteRunSeed(out int seed, out int sequence, o _remoteSeed = seed; _remoteSeedSequence = sequence; _remoteLaunchKind = launchKind; - _consumedRemoteSeedSequence = sequence; + MarkRemoteLaunchSequenceConsumedLocked(sequence); _seedArrived = true; Monitor.PulseAll(Sync); } @@ -388,6 +388,36 @@ public static bool TryConsumeNextRemoteRunSeed(out int seed, out int sequence, o return false; } + /// + /// Marks a remote launch sequence as consumed so host rebroadcasts of the same + /// RUNEXEC/SEED cannot schedule a false in-run restart after the client already + /// started that launch. + /// + internal static void MarkRemoteLaunchSequenceConsumed(int sequence, string reason) + { + if (sequence <= 0) + return; + + lock (Sync) + { + if (sequence <= _consumedRemoteSeedSequence) + return; + + MarkRemoteLaunchSequenceConsumedLocked(sequence); + } + + _log?.Information( + "[NetMod][RunLaunch] Marked remote launch consumed seq={Sequence} ({Reason})", + sequence, + reason); + } + + internal static void MarkRemoteLaunchSequenceConsumedLocked(int sequence) + { + if (sequence > _consumedRemoteSeedSequence) + _consumedRemoteSeedSequence = sequence; + } + public static void RefreshRoomStatusMenuIfVisible() { if (!_inHostStatusMenu && !_inClientWaitingMenu) @@ -396,7 +426,7 @@ public static void RefreshRoomStatusMenuIfVisible() return; _lastRoomStatusAutoRefresh = DateTime.UtcNow; - EnqueueMainThreadCoalesced("ui:auto-refresh-room-status", () => + MainThreadPump.EnqueueMainThreadCoalesced("ui:auto-refresh-room-status", () => { var screen = GetTitleScreen(); if (screen == null) @@ -429,25 +459,26 @@ internal static void NotifyProtocolMismatch( if (localRole != NetRole.Client) return; - EnqueueMainThreadCoalesced("ui:protocol-mismatch", () => + MainThreadPump.EnqueueMainThreadCoalesced("ui:protocol-mismatch", () => { var screen = GetTitleScreen(); if (screen == null) return; screen.clearMenu(); - AddInfoLine(screen, Localize("Co-op version mismatch"), 0xFF9090); - AddInfoLine(screen, detail, 0xE0E0E0); - AddInfoLine( - screen, + UiBegin(); + UiInfo(Localize("Co-op version mismatch"), 0xFF9090); + UiInfo(detail, 0xE0E0E0); + UiInfo( Localize("Install the exact same DeadCellsMultiplayerMod build on both computers."), 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("OK"), () => + UiButton(GetText.Instance.GetString("OK"), () => { screen.clearMenu(); ShowJoinTransportMenu(screen); }, Localize("Return to join menu")); - screen.ShouldAutoHideConnectionUI(false); + screen.ShouldAutoHideConnectionUI(true); + UiCommit(); }); } } diff --git a/UI/GameMenu.MultiplayerLaunch.cs b/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs similarity index 89% rename from UI/GameMenu.MultiplayerLaunch.cs rename to UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs index 61b4f4b..36a522a 100644 --- a/UI/GameMenu.MultiplayerLaunch.cs +++ b/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs @@ -6,30 +6,30 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { - private enum PendingLaunchAction + internal enum PendingLaunchAction { None, LoadSave, NewGame } - private static bool _launchHooksAttached; - private static PendingLaunchAction _pendingLaunchAction = PendingLaunchAction.NewGame; - private static bool _pendingLaunchCustom; - private static bool _pendingLaunchStreamEnabled; - private static bool _hasAuthoritativePendingNewGameLaunch; - private static bool _authoritativePendingNewGameCustom; - private static bool _authoritativePendingNewGameStreamEnabled; - private static string _cachedGeneratePayloadSignature = string.Empty; - private static string? _cachedGeneratePayloadJson; + internal static bool _launchHooksAttached; + internal static PendingLaunchAction _pendingLaunchAction = PendingLaunchAction.NewGame; + internal static bool _pendingLaunchCustom; + internal static bool _pendingLaunchStreamEnabled; + internal static bool _hasAuthoritativePendingNewGameLaunch; + internal static bool _authoritativePendingNewGameCustom; + internal static bool _authoritativePendingNewGameStreamEnabled; + internal static string _cachedGeneratePayloadSignature = string.Empty; + internal static string? _cachedGeneratePayloadJson; // Host Custom Mode rules live in save/customGameData_{slot}.json. The client must receive // that file before startNewGame(true); otherwise GameData.load/checkIntegrity null-derefs. - private static bool _remoteCustomGameDataReady; - private static string? _pendingRemoteCustomGameDataJson; + internal static bool _remoteCustomGameDataReady; + internal static string? _pendingRemoteCustomGameDataJson; - private static void InitializeMultiplayerLaunchHooks() + internal static void InitializeMultiplayerLaunchHooks() { if (_launchHooksAttached) return; @@ -39,7 +39,7 @@ private static void InitializeMultiplayerLaunchHooks() _launchHooksAttached = true; } - private static void Hook_TitleScreen_startNewGame(Hook_TitleScreen.orig_startNewGame orig, TitleScreen self, bool custom) + internal static void Hook_TitleScreen_startNewGame(Hook_TitleScreen.orig_startNewGame orig, TitleScreen self, bool custom) { var streamEnabled = TryGetStreamEnabled(self); NormalizePendingNewGameLaunch(ref custom, ref streamEnabled); @@ -51,7 +51,7 @@ private static void Hook_TitleScreen_startNewGame(Hook_TitleScreen.orig_startNew orig(self, custom); } - private static void Hook_TitleScreen_confirmNewGame(Hook_TitleScreen.orig_confirmNewGame orig, TitleScreen self, bool custom) + internal static void Hook_TitleScreen_confirmNewGame(Hook_TitleScreen.orig_confirmNewGame orig, TitleScreen self, bool custom) { var streamEnabled = TryGetStreamEnabled(self); NormalizePendingNewGameLaunch(ref custom, ref streamEnabled); @@ -61,7 +61,7 @@ private static void Hook_TitleScreen_confirmNewGame(Hook_TitleScreen.orig_confir orig(self, custom); } - private static void RememberPendingLaunch(PendingLaunchAction action, bool custom, bool streamEnabled, bool sendToRemote, bool assignNewCoopWorld = true) + internal static void RememberPendingLaunch(PendingLaunchAction action, bool custom, bool streamEnabled, bool sendToRemote, bool assignNewCoopWorld = true) { if (sendToRemote && (action != PendingLaunchAction.NewGame || assignNewCoopWorld)) PrepareCoopIdentityForPendingLaunch(action); @@ -85,7 +85,7 @@ private static void RememberPendingLaunch(PendingLaunchAction action, bool custo } } - private static void NormalizePendingNewGameLaunch(ref bool custom, ref bool streamEnabled) + internal static void NormalizePendingNewGameLaunch(ref bool custom, ref bool streamEnabled) { lock (Sync) { @@ -97,7 +97,7 @@ private static void NormalizePendingNewGameLaunch(ref bool custom, ref bool stre } } - private static void SetAuthoritativePendingNewGameLaunch(bool custom, bool streamEnabled) + internal static void SetAuthoritativePendingNewGameLaunch(bool custom, bool streamEnabled) { lock (Sync) { @@ -134,13 +134,13 @@ internal static bool TryGetAuthoritativePendingNewGameLaunch(out bool custom, ou return false; } - private static void InvalidateGeneratePayloadCacheLocked() + internal static void InvalidateGeneratePayloadCacheLocked() { _cachedGeneratePayloadSignature = string.Empty; _cachedGeneratePayloadJson = null; } - private static bool TryGetStreamEnabled(TitleScreen? screen) + internal static bool TryGetStreamEnabled(TitleScreen? screen) { try { @@ -152,12 +152,12 @@ private static bool TryGetStreamEnabled(TitleScreen? screen) } } - private static string GetModeLabel(bool isCustom) + internal static string GetModeLabel(bool isCustom) { return isCustom ? "Custom Mode" : "Normal Mode"; } - private static bool ResolveCurrentSaveIsCustom(TitleScreen? screen) + internal static bool ResolveCurrentSaveIsCustom(TitleScreen? screen) { try { @@ -175,21 +175,21 @@ private static bool ResolveCurrentSaveIsCustom(TitleScreen? screen) } } - private static string GetContinueButtonLabel(TitleScreen? screen) + internal static string GetContinueButtonLabel(TitleScreen? screen) { return string.Create( System.Globalization.CultureInfo.InvariantCulture, $"Continue ({GetModeLabel(ResolveCurrentSaveIsCustom(screen))})"); } - private static string GetStartNormalModeButtonLabel() + internal static string GetStartNormalModeButtonLabel() { return string.Create( System.Globalization.CultureInfo.InvariantCulture, $"{GetModeLabel(isCustom: false)}"); } - private static void ContinueHostRun(TitleScreen screen) + internal static void ContinueHostRun(TitleScreen screen) { if (!CanHostStartContinue(out var reason)) { @@ -208,7 +208,7 @@ private static void ContinueHostRun(TitleScreen screen) TryLaunchContinue(screen); } - private static void OpenHostCustomMode(TitleScreen screen) + internal static void OpenHostCustomMode(TitleScreen screen) { if (!AllPlayersReady()) return; @@ -244,7 +244,7 @@ private static void OpenHostCustomMode(TitleScreen screen) } } - private static bool EnsureCustomModeScreenUser(TitleScreen? screen) + internal static bool EnsureCustomModeScreenUser(TitleScreen? screen) { if (screen == null) return false; @@ -296,7 +296,7 @@ internal static bool PrepareUserForCustomModeLaunch(User? user) return TryPrepareCustomModeUser(user, out _); } - private static bool TryPrepareCustomModeUser(User? candidate, out User preparedUser) + internal static bool TryPrepareCustomModeUser(User? candidate, out User preparedUser) { preparedUser = null!; if (candidate == null) @@ -345,14 +345,14 @@ private static bool TryPrepareCustomModeUser(User? candidate, out User preparedU return true; } - private static string GetCustomGameDataRelativePath(int? slot = null) + internal static string GetCustomGameDataRelativePath(int? slot = null) { return string.Create( System.Globalization.CultureInfo.InvariantCulture, $"customGameData_{ResolveSaveSlotNumber(slot)}.json"); } - private static bool TryReadLocalCustomGameDataJson(out string json) + internal static bool TryReadLocalCustomGameDataJson(out string json) { json = string.Empty; try @@ -384,7 +384,7 @@ private static bool TryReadLocalCustomGameDataJson(out string json) } } - private static bool TryWriteLocalCustomGameDataJson(string json) + internal static bool TryWriteLocalCustomGameDataJson(string json) { if (string.IsNullOrWhiteSpace(json)) return false; @@ -430,7 +430,7 @@ private static bool TryWriteLocalCustomGameDataJson(string json) } } - private static void SendCustomGameDataToRemote() + internal static void SendCustomGameDataToRemote() { var net = NetRef; if (net == null || !net.IsAlive || !net.IsHost) @@ -484,7 +484,7 @@ public static void ReceiveCustomGameData(string? payload) } // Disk write must happen on the game thread-safe path before auto-start consumes it. - EnqueueCriticalMainThreadCoalesced( + MainThreadPump.EnqueueCriticalMainThreadCoalesced( "game:apply-custom-game-data", () => { @@ -520,7 +520,7 @@ internal static void ClearRemoteCustomGameDataState() } } - private static void StartHostRunNormalMode(TitleScreen screen) + internal static void StartHostRunNormalMode(TitleScreen screen) { if (!AllPlayersReady()) return; @@ -537,7 +537,7 @@ private static void StartHostRunNormalMode(TitleScreen screen) TryLaunchNewGame(screen, custom: false, TryGetStreamEnabled(screen)); } - private static void TryLaunchContinue(TitleScreen? screen) + internal static void TryLaunchContinue(TitleScreen? screen) { if (!TryBeginContinueLaunch()) return; @@ -570,7 +570,7 @@ private static void TryLaunchContinue(TitleScreen? screen) } } - private static bool TryBeginContinueLaunch() + internal static bool TryBeginContinueLaunch() { var now = DateTime.UtcNow; lock (Sync) @@ -587,7 +587,7 @@ private static bool TryBeginContinueLaunch() } } - private static void ClearContinueLaunchGuard() + internal static void ClearContinueLaunchGuard() { lock (Sync) { @@ -596,7 +596,7 @@ private static void ClearContinueLaunchGuard() } } - private static void TrySendContinueLaunchPrerequisites(TitleScreen? screen) + internal static void TrySendContinueLaunchPrerequisites(TitleScreen? screen) { var net = NetRef; if (net == null || !net.IsAlive || !net.IsHost) @@ -610,7 +610,7 @@ private static void TrySendContinueLaunchPrerequisites(TitleScreen? screen) GameDataSync.SendCurrentHeroCosmetics(user, net, force: true); } - private static User? TryResolveContinueUser(TitleScreen? screen) + internal static User? TryResolveContinueUser(TitleScreen? screen) { try { @@ -640,7 +640,7 @@ private static void TrySendContinueLaunchPrerequisites(TitleScreen? screen) return null; } - private static void TryLaunchNewGame(TitleScreen? screen, bool custom, bool streamEnabled) + internal static void TryLaunchNewGame(TitleScreen? screen, bool custom, bool streamEnabled) { SetAuthoritativePendingNewGameLaunch(custom, streamEnabled); @@ -726,7 +726,7 @@ private static void TryLaunchNewGame(TitleScreen? screen, bool custom, bool stre } } - private static string BuildGeneratePayloadJson(LevelDescSync? levelDesc) + internal static string BuildGeneratePayloadJson(LevelDescSync? levelDesc) { PendingLaunchAction action; bool custom; @@ -777,7 +777,7 @@ private static string BuildGeneratePayloadJson(LevelDescSync? levelDesc) return json; } - private static void ApplyReceivedPendingLaunch(string? actionText, bool launchCustom, bool launchStreamEnabled) + internal static void ApplyReceivedPendingLaunch(string? actionText, bool launchCustom, bool launchStreamEnabled) { PendingLaunchAction action; if (!Enum.TryParse(actionText, ignoreCase: true, out action)) @@ -836,7 +836,7 @@ public static void ReceiveLaunchMode( RequestLobbyMenuRefresh(); } - private static void SendLaunchModeToRemote() + internal static void SendLaunchModeToRemote() { var net = NetRef; if (net == null || !net.IsAlive || !net.IsHost) @@ -872,8 +872,8 @@ private static void SendLaunchModeToRemote() /// with everything in hand; if it is not, we still start and let the generation-time wait /// (which is where the data is actually needed) do its job. /// - private const int ClientLevelGraphPreferenceGraceMs = 5000; - private static long _clientLevelGraphWaitStartedTicks; + internal const int ClientLevelGraphPreferenceGraceMs = 5000; + internal static long _clientLevelGraphWaitStartedTicks; /// /// Sticky for the current launch once the grace window expires. /// @@ -885,11 +885,11 @@ private static void SendLaunchModeToRemote() /// then report "not ready" to the claim a microsecond later, arming and disarming forever /// on a 5s cycle without ever launching. Cleared with the rest of the launch state. /// - private static bool _clientLevelGraphWaitExpired; - private static long _nextClientLaunchBlockLogTicks; - private const int ClientLaunchBlockLogIntervalMs = 5000; + internal static bool _clientLevelGraphWaitExpired; + internal static long _nextClientLaunchBlockLogTicks; + internal const int ClientLaunchBlockLogIntervalMs = 5000; - private static bool IsPendingLaunchReadyForAutoStartLocked() + internal static bool IsPendingLaunchReadyForAutoStartLocked() { if (_pendingLaunchAction == PendingLaunchAction.LoadSave) { @@ -921,7 +921,7 @@ private static bool IsPendingLaunchReadyForAutoStartLocked() return IsRemoteRunSyncReadyForLaunchLocked(); } - private static bool IsRemoteRunSyncReadyForLaunchLocked() + internal static bool IsRemoteRunSyncReadyForLaunchLocked() { if (!GameDataSync.HasRemoteBossRune()) { @@ -966,7 +966,7 @@ private static bool IsRemoteRunSyncReadyForLaunchLocked() /// Rate-limited report of the single prerequisite currently blocking the client auto-start. /// Without this, "the client stayed in the lobby" produced no evidence at all. /// - private static void LogClientLaunchBlockLocked(string reason) + internal static void LogClientLaunchBlockLocked(string reason) { if (_role != NetRole.Client) return; @@ -995,7 +995,7 @@ private static void LogClientLaunchBlockLocked(string reason) /// both branches below fall back to Main.launchGame — because the alternative is a client /// that holds a fully valid authoritative launch and never leaves the menu. /// - private static void TryAutoStartPendingLaunch(TitleScreen? screen) + internal static void TryAutoStartPendingLaunch(TitleScreen? screen) { PendingLaunchAction action; bool custom; diff --git a/UI/GameMenu.RunLaunch.cs b/UI/ConnectionUI/RunLaunchFlow.cs similarity index 95% rename from UI/GameMenu.RunLaunch.cs rename to UI/ConnectionUI/RunLaunchFlow.cs index 34420d2..2b962c6 100644 --- a/UI/GameMenu.RunLaunch.cs +++ b/UI/ConnectionUI/RunLaunchFlow.cs @@ -7,25 +7,25 @@ namespace DeadCellsMultiplayerMod; -internal static partial class GameMenu +internal static partial class LobbySession { - private static bool _structuredLaunchCommitArrived; - private static int _structuredLaunchExecuteSequence; + internal static bool _structuredLaunchCommitArrived; + internal static int _structuredLaunchExecuteSequence; - private static void InitializeRunLaunchHandshake(ILogger logger) + internal static void InitializeRunLaunchHandshake(ILogger logger) { RunLaunchCoordinator.Initialize(logger); lock (Sync) ClearStructuredLaunchFlagsLocked(); } - private static void ClearStructuredLaunchFlagsLocked() + internal static void ClearStructuredLaunchFlagsLocked() { _structuredLaunchCommitArrived = false; _structuredLaunchExecuteSequence = 0; } - private static RunLaunchDescriptor BuildHostRunLaunchDescriptor( + internal static RunLaunchDescriptor BuildHostRunLaunchDescriptor( int seed, int sequence, string launchKind, @@ -57,7 +57,7 @@ private static RunLaunchDescriptor BuildHostRunLaunchDescriptor( targetArena: initialLevelId); } - private static int ReadBossCellsForLaunch() + internal static int ReadBossCellsForLaunch() { try { @@ -304,6 +304,11 @@ internal static void NotifyClientLaunchQueued(int sequence) if (queued == null) return; + // The client is invoking the authoritative native loader for this sequence now. + // Consume it immediately so later host SEED/RUNEXEC rebroadcasts cannot schedule + // unconsumed_host_launch_seq_* after the world is already built. + MarkRemoteLaunchSequenceConsumed(sequence, "client_launch_queued"); + net.SendRunLaunchQueued(queued, flush: true); _log?.Information("[NetMod][RunLaunch] Client sent RUNQUEUED seq={Sequence}", sequence); BossSyncDiag.Trace("launch queued role=client seq={Sequence}", sequence); @@ -442,7 +447,7 @@ internal static void ReceiveRunLevelReadyPayload(string payload) } } - private static bool _midRunJoinSpawnPending; + internal static bool _midRunJoinSpawnPending; /// Consumed once by the joining client's first level; false for every normal launch. internal static bool TryConsumeMidRunJoinSpawn() @@ -482,7 +487,7 @@ internal static void ReceiveRunLaunchCancelPayload(string payload) MultiplayerUI.PushSystemMessage(Localize("Host cancelled the co-op run launch.")); } - private static void CancelHostStructuredLaunch(int sequence, string reason) + internal static void CancelHostStructuredLaunch(int sequence, string reason) { StopHostRunLaunchBeacon("host_launch_cancelled"); var cancel = RunLaunchCoordinator.CancelHostLaunch(sequence, reason); @@ -491,7 +496,7 @@ private static void CancelHostStructuredLaunch(int sequence, string reason) NetRef?.ClearCachedHostRunLaunch(sequence); } - private static bool CanAutoStartStructuredClientLaunchLocked() + internal static bool CanAutoStartStructuredClientLaunchLocked() { return _structuredLaunchCommitArrived && _structuredLaunchExecuteSequence > 0 && diff --git a/UI/ConnectionUI/SaveGuard.Api.cs b/UI/ConnectionUI/SaveGuard.Api.cs new file mode 100644 index 0000000..8022bf3 --- /dev/null +++ b/UI/ConnectionUI/SaveGuard.Api.cs @@ -0,0 +1,8 @@ +namespace DeadCellsMultiplayerMod; + +/// Multiplayer save-guard API surface (implementation lives in LobbySession SaveGuard partial). +internal static class SaveGuard +{ + internal static void NotifyRunLaunchPhaseForSaveGuard(string phase) + => LobbySession.NotifyRunLaunchPhaseForSaveGuard(phase); +} diff --git a/UI/GameMenu.SaveGuard.cs b/UI/ConnectionUI/SaveGuard.cs similarity index 93% rename from UI/GameMenu.SaveGuard.cs rename to UI/ConnectionUI/SaveGuard.cs index fc2fbd6..0b17ce4 100644 --- a/UI/GameMenu.SaveGuard.cs +++ b/UI/ConnectionUI/SaveGuard.cs @@ -1,6 +1,6 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { // Crash-durable multiplayer save protection. Dead Cells parses the active slot's // user_N.dat (meta + current run snapshot) at every run launch; a file corrupted by an @@ -14,20 +14,20 @@ internal static partial class GameMenu // 3. On the next startup after a crash inside that window, restores the newest // backup automatically and quarantines the corrupt file for inspection. - private const string MultiplayerSaveGuardSentinelName = "launch_guard.txt"; + internal const string MultiplayerSaveGuardSentinelName = "launch_guard.txt"; // Backup rotation copies the whole slot file twice. It runs on the game main thread from // the session state machine at every LoadingLevel transition, i.e. once per biome, so // repeating it when the save has not changed since the last rotation is pure stall for no // added protection. Identity is (length, last write time) of the live file. - private static long _lastRotatedSaveLength = -1; - private static long _lastRotatedSaveWriteTicks = -1; - private static int _lastRotatedSaveSlot = -1; + internal static long _lastRotatedSaveLength = -1; + internal static long _lastRotatedSaveWriteTicks = -1; + internal static int _lastRotatedSaveSlot = -1; - private static string GetMultiplayerSaveGuardSentinelPath() + internal static string GetMultiplayerSaveGuardSentinelPath() => GetAbsoluteSavePath(MultiplayerSaveFolderName + "/" + MultiplayerSaveGuardSentinelName); - private static string GetMultiplayerSaveLivePath(int slot) + internal static string GetMultiplayerSaveLivePath(int slot) => GetAbsoluteSavePath(GetMultiplayerSaveRelativeFilePath(slot)); internal static void NotifyRunLaunchPhaseForSaveGuard(string phase) @@ -52,7 +52,7 @@ internal static void NotifyRunLaunchPhaseForSaveGuard(string phase) } } - private static void ArmMultiplayerSaveGuard() + internal static void ArmMultiplayerSaveGuard() { try { @@ -104,7 +104,7 @@ private static void ArmMultiplayerSaveGuard() } } - private static void DisarmMultiplayerSaveGuard() + internal static void DisarmMultiplayerSaveGuard() { try { diff --git a/UI/ConnectionUI/TitleMenuHooks.Api.cs b/UI/ConnectionUI/TitleMenuHooks.Api.cs new file mode 100644 index 0000000..cb8b351 --- /dev/null +++ b/UI/ConnectionUI/TitleMenuHooks.Api.cs @@ -0,0 +1,8 @@ +namespace DeadCellsMultiplayerMod; + +/// Title-screen multiplayer button hooks (implementation lives in LobbySession TitleMenuHooks partial). +internal static class TitleMenuHooks +{ + internal static void InitializeMenuUiHooks() + => LobbySession.InitializeMenuUiHooks(); +} diff --git a/UI/GameMenuHooks.cs b/UI/ConnectionUI/TitleMenuHooks.cs similarity index 91% rename from UI/GameMenuHooks.cs rename to UI/ConnectionUI/TitleMenuHooks.cs index 1166c15..e3ef14b 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/ConnectionUI/TitleMenuHooks.cs @@ -9,7 +9,7 @@ namespace DeadCellsMultiplayerMod { - internal static partial class GameMenu + internal static partial class LobbySession { /// /// Accent colour for the main-menu "Play multiplayer" entry, and for that entry only. @@ -24,14 +24,14 @@ internal static partial class GameMenu /// internal const int MultiplayerMenuAccentColor = 0x59D5FF; - private static void InitializeMenuUiHooks() + internal static void InitializeMenuUiHooks() { if (_menuHooksAttached) return; try { LoadConfig(); - InitializeMultiplayerSaveHooks(); + MultiplayerSaves.InitializeMultiplayerSaveHooks(); InitializeMultiplayerLaunchHooks(); Hook_TitleScreen.mainMenu += MainMenuHook; _menuHooksAttached = true; @@ -42,7 +42,7 @@ private static void InitializeMenuUiHooks() } } - private static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScreen self) + internal static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScreen self) { ModEntry.PumpSteamCallbacksForOverlay(); if (!_addMenuHookRegistered) @@ -64,7 +64,7 @@ private static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScree ProcessPendingOverlayJoinRequest(self); } - private static void ResetOriginalMainMenuUiState() + internal static void ResetOriginalMainMenuUiState() { ResetHostDisconnectCountdown(); _inHostStatusMenu = false; @@ -73,7 +73,7 @@ private static void ResetOriginalMainMenuUiState() ConnectionUI.set_visible = false; } - private static void ProcessPendingOverlayJoinRequest(TitleScreen screen) + internal static void ProcessPendingOverlayJoinRequest(TitleScreen screen) { if (_pendingOverlayJoinLobbyId is not { } lobbyId) return; @@ -82,7 +82,7 @@ private static void ProcessPendingOverlayJoinRequest(TitleScreen screen) HandleSteamOverlayJoinRequest(lobbyId); } - private static void TryDisconnectWhenReturningToMainMenu() + internal static void TryDisconnectWhenReturningToMainMenu() { if (_role == NetRole.None) return; @@ -92,7 +92,7 @@ private static void TryDisconnectWhenReturningToMainMenu() StopNetworkFromMenu(); } - private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( + internal static virtual_cb_help_inter_isEnable_t_ AddMenuHook( Hook_TitleScreen.orig_addMenu orig, TitleScreen self, dc.String str, @@ -102,7 +102,7 @@ private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( Ref color) { ModEntry.PumpSteamCallbacksForOverlay(); - GameMenu.ProcessMainThreadQueue(); + MainThreadPump.ProcessMainThreadQueue(); var wrappedCb = WrapQuitCallbackIfNeeded(str, cb); var ret = orig(self, str, wrappedCb ?? cb, help, isEnable, color); @@ -135,7 +135,7 @@ private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( return ret; } - private static HlAction? WrapQuitCallbackIfNeeded(dc.String label, HlAction? callback) + internal static HlAction? WrapQuitCallbackIfNeeded(dc.String label, HlAction? callback) { if (callback == null) return null; @@ -155,7 +155,7 @@ private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( }); } - private static bool IsQuitMenuLabel(string label) + internal static bool IsQuitMenuLabel(string label) { if (string.IsNullOrWhiteSpace(label)) return false; @@ -175,7 +175,7 @@ private static bool IsQuitMenuLabel(string label) return false; } - private static void EnsureMainMenuMultiplayerButton(TitleScreen screen) + internal static void EnsureMainMenuMultiplayerButton(TitleScreen screen) { try { @@ -218,7 +218,7 @@ private static void EnsureMainMenuMultiplayerButton(TitleScreen screen) /// which path won the race. Lookup is by the localized label, so exactly one entry is /// touched and no unrelated menu item changes colour. /// - private static void ApplyMultiplayerMenuAccent(object? menuItemsArray, string playMultiplayerLabel) + internal static void ApplyMultiplayerMenuAccent(object? menuItemsArray, string playMultiplayerLabel) { try { @@ -240,7 +240,7 @@ private static void ApplyMultiplayerMenuAccent(object? menuItemsArray, string pl } } - private static void MoveButtonAfterPlay(object? arrObj, string targetLabel, string anchorLabel) + internal static void MoveButtonAfterPlay(object? arrObj, string targetLabel, string anchorLabel) { if (arrObj == null) return; try diff --git a/UI/ConnectionUI/_ConnectionUI.cs b/UI/ConnectionUI/_ConnectionUI.cs index e80cfd9..3780e48 100644 --- a/UI/ConnectionUI/_ConnectionUI.cs +++ b/UI/ConnectionUI/_ConnectionUI.cs @@ -1,33 +1,105 @@ +using dc; using dc.pr; using dc.ui; using DeadCellsMultiplayerMod.Interface.ModuleInitializing; using ModCore.Events; using ModCore.Utilities; using System.Collections.Generic; +using System.Text; namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection { public static class _ConnectionUI { - /// Sentinel for ; displayed in ConnectionUI only. + /// Sentinel for ; displayed in ConnectionUI only. internal const string SteamLobbyConnectingMarker = "_steamLobbyConnecting"; + /// Fixed lobby preview capacity: 1 host + clients. + internal static int LobbySlotCount => NetNode.MaxClientSlots + 1; + + internal readonly struct LobbyPlayerSlot + { + public readonly bool Occupied; + public readonly string Nick; + public readonly string Skin; + public readonly bool IsHost; + public readonly bool IsYou; + public readonly bool IsConnecting; + + public LobbyPlayerSlot(bool occupied, string nick, string skin, bool isHost, bool isYou, bool isConnecting) + { + Occupied = occupied; + Nick = nick ?? string.Empty; + Skin = string.IsNullOrWhiteSpace(skin) ? "PrisonerDefault" : skin.Trim(); + IsHost = isHost; + IsYou = isYou; + IsConnecting = isConnecting; + } + + public static LobbyPlayerSlot Empty => new(false, string.Empty, "PrisonerDefault", false, false, false); + } + public static List GetAllPlayerNames() { - var playerNames = new List(); + var slots = GetLobbyPlayerSlots(); + var playerNames = new List(slots.Count); + for (int i = 0; i < slots.Count; i++) + { + var slot = slots[i]; + if (!slot.Occupied) + continue; + + if (slot.IsConnecting) + { + playerNames.Add(slot.Nick); + continue; + } + + var label = slot.Nick; + if (slot.IsHost && slot.IsYou) + label += " (Host) (you)"; + else if (slot.IsHost) + label += " (Host)"; + else if (slot.IsYou) + label += " (you)"; + playerNames.Add(label); + } + + return playerNames; + } + + /// + /// Four fixed lobby seats used by the beheaded players row. + /// Slot 0 is always the host seat; remaining seats fill with connected peers. + /// + internal static List GetLobbyPlayerSlots() + { + int capacity = LobbySlotCount; + var slots = new List(capacity); + for (int i = 0; i < capacity; i++) + slots.Add(LobbyPlayerSlot.Empty); var net = ModEntry._net; if (net == null) { - if (GameMenu.IsSteamJoinLobbyResolvePending()) - playerNames.Add(_ConnectionUI.SteamLobbyConnectingMarker); - return playerNames; + if (LobbySession.IsSteamJoinLobbyResolvePending()) + { + slots[0] = new LobbyPlayerSlot( + occupied: true, + nick: SteamLobbyConnectingMarker, + skin: "PrisonerDefault", + isHost: false, + isYou: true, + isConnecting: true); + } + return slots; } - var localName = GameMenu.Username; + var localName = LobbySession.Username; if (string.IsNullOrWhiteSpace(localName)) localName = "Guest"; + var localSkin = ResolveLocalHeroSkin(); var hasSnapshots = net.TryGetRemoteUserSnapshots(out var snapshots); try { @@ -37,17 +109,53 @@ public static List GetAllPlayerNames() if (!net.HasRemote && !isHost) { - playerNames.Add("connecting..."); - return playerNames; + slots[0] = new LobbyPlayerSlot( + occupied: true, + nick: "connecting...", + skin: "PrisonerDefault", + isHost: false, + isYou: true, + isConnecting: true); + return slots; } if (isHost) { - playerNames.Add(localName + " (Host) (you)"); + slots[0] = new LobbyPlayerSlot( + occupied: true, + nick: localName, + skin: localSkin, + isHost: true, + isYou: true, + isConnecting: false); + + int write = 1; + if (hasSnapshots) + { + for (int i = 0; i < snapshots.Count && write < capacity; i++) + { + var remote = snapshots[i]; + if (remote.Id == hostId) + continue; + if (localId > 0 && remote.Id == localId) + continue; + + string displayName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); + string skin = ResolveRemoteSkin(localId, remote.Id); + slots[write++] = new LobbyPlayerSlot( + occupied: true, + nick: displayName, + skin: skin, + isHost: false, + isYou: false, + isConnecting: false); + } + } } else { - string? hostName = null; + string hostName = "Host"; + string hostSkin = "PrisonerDefault"; if (hasSnapshots) { for (int i = 0; i < snapshots.Count; i++) @@ -57,37 +165,66 @@ public static List GetAllPlayerNames() continue; hostName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); + hostSkin = ResolveRemoteSkin(localId, remote.Id); break; } } - if (string.IsNullOrWhiteSpace(hostName)) + if (string.IsNullOrWhiteSpace(hostName) || + string.Equals(hostName, "Guest", StringComparison.OrdinalIgnoreCase)) { - var fallbackHost = GameMenu.RemoteUsername; - hostName = string.IsNullOrWhiteSpace(fallbackHost) ? "Host" : fallbackHost.Trim(); + var fallbackHost = LobbySession.RemoteUsername; + if (!string.IsNullOrWhiteSpace(fallbackHost)) + hostName = fallbackHost.Trim(); } - playerNames.Add(hostName + " (Host)"); - playerNames.Add(localName + " (you)"); - } - if (hasSnapshots) - { - for (int i = 0; i < snapshots.Count; i++) + if (string.IsNullOrWhiteSpace(hostSkin) || + string.Equals(hostSkin, "PrisonerDefault", StringComparison.Ordinal)) { - var remote = snapshots[i]; - if (remote.Id == hostId) - continue; - if (!isHost && remote.Id == localId) - continue; - if (isHost && localId > 0 && remote.Id == localId) - continue; - - string displayName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); - playerNames.Add(displayName); + var cachedHost = ModEntry.Instance?.remoteSkin; + if (!string.IsNullOrWhiteSpace(cachedHost)) + hostSkin = cachedHost.Trim(); + } + + slots[0] = new LobbyPlayerSlot( + occupied: true, + nick: hostName, + skin: hostSkin, + isHost: true, + isYou: false, + isConnecting: false); + + slots[1] = new LobbyPlayerSlot( + occupied: true, + nick: localName, + skin: localSkin, + isHost: false, + isYou: true, + isConnecting: false); + + int write = 2; + if (hasSnapshots) + { + for (int i = 0; i < snapshots.Count && write < capacity; i++) + { + var remote = snapshots[i]; + if (remote.Id == hostId || remote.Id == localId) + continue; + + string displayName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); + string skin = ResolveRemoteSkin(localId, remote.Id); + slots[write++] = new LobbyPlayerSlot( + occupied: true, + nick: displayName, + skin: skin, + isHost: false, + isYou: false, + isConnecting: false); + } } } - return playerNames; + return slots; } finally { @@ -96,6 +233,27 @@ public static List GetAllPlayerNames() } } + /// Compact signature so lobby UI refreshes when nick OR skin changes. + internal static string BuildLobbySlotsSignature(List slots) + { + var sb = new StringBuilder(slots.Count * 24); + for (int i = 0; i < slots.Count; i++) + { + var s = slots[i]; + if (i > 0) + sb.Append('\u001f'); + sb.Append(s.Occupied ? '1' : '0'); + sb.Append('|'); + sb.Append(s.Nick); + sb.Append('|'); + sb.Append(s.Skin); + sb.Append('|'); + sb.Append(s.IsHost ? 'H' : '-'); + sb.Append(s.IsYou ? 'Y' : '-'); + sb.Append(s.IsConnecting ? 'C' : '-'); + } + return sb.ToString(); + } public static string GetPlayerName(int localId, int remoteId, string remoteUsername) { @@ -117,11 +275,78 @@ public static string GetPlayerName(int localId, int remoteId, string remoteUsern } return string.IsNullOrWhiteSpace(remoteUsername) ? "Guest" : remoteUsername.Trim(); } + public static bool ShouldAutoHideConnectionUI(this TitleScreen titleScreen, bool visible) { ConnectionUI.set_visible = visible; return visible; } + internal static string ResolveLocalHeroSkin() + { + try + { + User? user = null; + try { user = Main.Class.ME?.user; } catch { } + if (user == null) + { + try { user = Game.Class.ME?.user; } catch { } + } + + string? raw = null; + try { raw = user?.heroSkin?.ToString(); } catch { } + + if (!string.IsNullOrWhiteSpace(raw)) + { + var cleaned = raw.Replace("|", "/").Trim(); + try + { + var info = Cdb.Class.getSkinInfo(cleaned.AsHaxeString()); + var cmd = info?.consoleCmdId?.ToString(); + if (!string.IsNullOrWhiteSpace(cmd)) + return cmd.Replace("|", "/").Trim(); + } + catch + { + } + + return cleaned; + } + } + catch + { + } + + return "PrisonerDefault"; + } + + private static string ResolveRemoteSkin(int localId, int remoteId) + { + if (ModEntry.TryGetClientIndex(localId, remoteId, out var slotIndex)) + { + if ((uint)slotIndex < (uint)ModEntry.clientSkins.Length) + { + var known = ModEntry.clientSkins[slotIndex]; + if (!string.IsNullOrWhiteSpace(known)) + return known.Replace("|", "/").Trim(); + } + } + + if (remoteId == 1) + { + var hostSkin = ModEntry.Instance?.remoteSkin; + if (!string.IsNullOrWhiteSpace(hostSkin)) + return hostSkin.Replace("|", "/").Trim(); + } + + if (ModEntry._net != null && + ModEntry._net.TryGetRemoteSkin(remoteId, out var netSkin) && + !string.IsNullOrWhiteSpace(netSkin)) + { + return netSkin.Replace("|", "/").Trim(); + } + + return "PrisonerDefault"; + } } } diff --git a/UI/LevelExitSync.cs b/UI/LevelExitSync.cs index 3a39408..f647db5 100644 --- a/UI/LevelExitSync.cs +++ b/UI/LevelExitSync.cs @@ -204,7 +204,7 @@ private void TryPrecommitBossRushEntranceSeed(BossRushDoor door, Hero by) return; var localHero = ModEntry.me; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (localHero == null || !ReferenceEquals(by, localHero) || net == null || !net.IsAlive || !net.IsHost) { @@ -222,7 +222,7 @@ private void TryPrecommitBossRushEntranceSeed(BossRushDoor door, Hero by) return; var bossRushType = SafeRead(() => door.bossRushType?.ToString() ?? string.Empty, string.Empty); - if (GameMenu.PrecommitHostBossRushRunSeed( + if (LobbySession.PrecommitHostBossRushRunSeed( bossRushType, door.cx, door.cy, @@ -297,7 +297,7 @@ private void HandleExitTargetActivate(T target, Hero by, Action origActivate, return; } - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive || net.id <= 0) { origActivate(); @@ -356,7 +356,7 @@ private void HandleExitTargetActivate(T target, Hero by, Action origActivate, void IOnHeroUpdate.OnHeroUpdate(double dt) { var hero = ModEntry.me; - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (hero == null || net == null || !net.IsAlive || net.id <= 0) { if (_lastLevel != null || _doorVisuals.Count > 0 || _playerStates.Count > 0 || _exitPointer != null) @@ -799,7 +799,7 @@ private static void InvokeExitTargetActivate(Entity target, Hero hero) /// private bool TryPassBossRushLaunchGate(Entity target) { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net == null || !net.IsAlive) return true; @@ -812,7 +812,7 @@ private bool TryPassBossRushLaunchGate(Entity target) return true; } - if (GameMenu.TryBeginLocalBossRushLoad(out var reason)) + if (LobbySession.TryBeginLocalBossRushLoad(out var reason)) { // Validate the real, runtime-sourced Boss Rush variant (this door's bossRushType) against // the authoritative host Route before the client invokes the native loader. A mismatch is @@ -820,7 +820,7 @@ private bool TryPassBossRushLaunchGate(Entity target) if (!net.IsHost) { var localVariant = SafeRead(() => (target as BossRushDoor)?.bossRushType?.ToString() ?? string.Empty, string.Empty); - GameMenu.ValidateClientBossRushVariant(localVariant); + LobbySession.ValidateClientBossRushVariant(localVariant); } if (!string.IsNullOrEmpty(_bossRushGateDoorKey)) BossSyncDiag.Trace("launch gate cleared role={Role} door={Door}", BossSyncDiag.Role(net), _bossRushGateDoorKey); @@ -851,7 +851,7 @@ private bool TryPassBossRushLaunchGate(Entity target) BossSyncDiag.Role(net), key, reason); - GameMenu.CancelBossRushLaunchGate($"boss rush launch sync timed out ({reason})"); + LobbySession.CancelBossRushLaunchGate($"boss rush launch sync timed out ({reason})"); MultiplayerUI.PushSystemMessage( Localize("Boss Rush could not synchronize with your friend. Approach the door again to retry."), 7.0, @@ -1164,7 +1164,7 @@ private static string ResolveUserDisplayName(int userId, NetNode net) return Localize("Guest"); if (net.id > 0 && userId == net.id) - return string.IsNullOrWhiteSpace(GameMenu.Username) ? Localize("Guest") : GameMenu.Username.Trim(); + return string.IsNullOrWhiteSpace(LobbySession.Username) ? Localize("Guest") : LobbySession.Username.Trim(); if (net.TryGetRemoteUsername(userId, out var username)) { @@ -1173,8 +1173,8 @@ private static string ResolveUserDisplayName(int userId, NetNode net) return name; } - if (userId == 1 && !string.IsNullOrWhiteSpace(GameMenu.RemoteUsername)) - return GameMenu.RemoteUsername.Trim(); + if (userId == 1 && !string.IsNullOrWhiteSpace(LobbySession.RemoteUsername)) + return LobbySession.RemoteUsername.Trim(); return FormatLocalized("Player {0}", userId); } @@ -1303,7 +1303,7 @@ private static bool IsAvailableExitTarget(Entity? entity) return false; // Prevent boss arena doors from being available for clients during active boss fights - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net != null && !net.IsHost && IsInBossFight()) return false; @@ -1368,7 +1368,7 @@ private DoorVisual EnsureDoorVisual(Entity target) try { - var net = GameMenu.NetRef; + var net = LobbySession.NetRef; if (net != null && net.IsAlive) EnsureReadyStateCache(net); diff --git a/UI/MultiplayerUI.cs b/UI/MultiplayerUI.cs index 7ff9496..1ba1e46 100644 --- a/UI/MultiplayerUI.cs +++ b/UI/MultiplayerUI.cs @@ -243,7 +243,7 @@ public void KingLifeUpdate(Hero self) var displayName = ModEntry.GetClientLabel(slotIndex); if (string.IsNullOrWhiteSpace(displayName) || string.Equals(displayName, "Guest", StringComparison.OrdinalIgnoreCase) || - string.Equals(displayName, GameMenu.RemoteUsername, StringComparison.Ordinal)) + string.Equals(displayName, LobbySession.RemoteUsername, StringComparison.Ordinal)) { if (!string.IsNullOrWhiteSpace(remote.Username)) displayName = remote.Username.Trim(); diff --git a/UI/SettingsUI.cs b/UI/SettingsUI.cs index 67146f2..03ee354 100644 --- a/UI/SettingsUI.cs +++ b/UI/SettingsUI.cs @@ -82,7 +82,7 @@ private void Hook_Options_showMain(Hook_Options.orig_showMain orig, Options self // Insert before vanilla entries so it appears at the top. self.addSimpleWidget( - GameMenu.Localize("Multiplayer settings").AsHaxeString(), + LobbySession.Localize("Multiplayer settings").AsHaxeString(), null, onSelect, Ref.From(ref leftPadding), @@ -158,7 +158,7 @@ private void BuildMultiplayerSettingsSection(Options self) if (!IsMultiplayerSettingsContext(self)) return; - self.title?.set_text(GameMenu.Localize("Multiplayer settings").AsHaxeString()); + self.title?.set_text(LobbySession.Localize("Multiplayer settings").AsHaxeString()); self.createScroller(0.0); var widgetParent = self.scrollerFlow; @@ -176,7 +176,7 @@ private void BuildMultiplayerSettingsSection(Options self) }); self.addSimpleWidget( - GameMenu.Localize("Back").AsHaxeString(), + LobbySession.Localize("Back").AsHaxeString(), null, onBack, Ref.From(ref leftPadding), @@ -199,7 +199,7 @@ private void AddMobsSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool enabledNow = MultiplayerSettingsStorage.EnableMobsSync; self.addToggleWidget( - GameMenu.Localize("Enable mobs sync").AsHaxeString(), + LobbySession.Localize("Enable mobs sync").AsHaxeString(), null, new HlFunc(ToggleMobsSyncSetting), Ref.From(ref enabledNow), @@ -213,7 +213,7 @@ private void AddMobsSettingsWidgets(Options self, dc.h2d.Flow widgetParent) double mobsHpMax = 8.00; self.addSliderWidget( - GameMenu.Localize("Mobs HP multiplier").AsHaxeString(), + LobbySession.Localize("Mobs HP multiplier").AsHaxeString(), new HlAction(OnMobsHpSliderChanged), mobsHpValue, Ref.From(ref mobsHpStep), @@ -233,7 +233,7 @@ private void AddMobsSettingsWidgets(Options self, dc.h2d.Flow widgetParent) double bossesHpMax = 8.00; self.addSliderWidget( - GameMenu.Localize("Bosses HP multiplier").AsHaxeString(), + LobbySession.Localize("Bosses HP multiplier").AsHaxeString(), new HlAction(OnBossesHpSliderChanged), bossesHpValue, Ref.From(ref bossesHpStep), @@ -247,8 +247,8 @@ private void AddMobsSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool verticalSyncNow = MultiplayerSettingsStorage.SyncVerticalPosition; self.addToggleWidget( - GameMenu.Localize("Sync vertical position").AsHaxeString(), - GameMenu.Localize( + LobbySession.Localize("Sync vertical position").AsHaxeString(), + LobbySession.Localize( "Applies host vertical position to gravity mobs. Off: X-only for walkers; flying mobs still sync Y. On: can reduce desync but may snap ground mobs.") .AsHaxeString(), new HlFunc(ToggleVerticalSyncSetting), @@ -260,16 +260,16 @@ private static string GetDebugModuleToggleLabel(DebugModuleId id) { return id switch { - DebugModuleId.MultiplayerModLang => GameMenu.Localize("Module: language"), - DebugModuleId.CineHooks => GameMenu.Localize("Module: cinematics hooks"), - DebugModuleId.MultiplayerUI => GameMenu.Localize("Module: multiplayer UI"), - DebugModuleId.LevelInit => GameMenu.Localize("Module: level init"), - DebugModuleId.MobsSynchronization => GameMenu.Localize("Module: mobs sync"), - DebugModuleId.MinimapReveal => GameMenu.Localize("Module: minimap reveal"), - DebugModuleId.LevelExitSync => GameMenu.Localize("Module: level exit sync"), - DebugModuleId.InteractionSync => GameMenu.Localize("Module: interaction sync"), - DebugModuleId.ConnectionUI => GameMenu.Localize("Module: connection UI"), - _ => GameMenu.Localize("Module") + DebugModuleId.MultiplayerModLang => LobbySession.Localize("Module: language"), + DebugModuleId.CineHooks => LobbySession.Localize("Module: cinematics hooks"), + DebugModuleId.MultiplayerUI => LobbySession.Localize("Module: multiplayer UI"), + DebugModuleId.LevelInit => LobbySession.Localize("Module: level init"), + DebugModuleId.MobsSynchronization => LobbySession.Localize("Module: mobs sync"), + DebugModuleId.MinimapReveal => LobbySession.Localize("Module: minimap reveal"), + DebugModuleId.LevelExitSync => LobbySession.Localize("Module: level exit sync"), + DebugModuleId.InteractionSync => LobbySession.Localize("Module: interaction sync"), + DebugModuleId.ConnectionUI => LobbySession.Localize("Module: connection UI"), + _ => LobbySession.Localize("Module") }; } @@ -293,7 +293,7 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool immortalNow = MultiplayerSettingsStorage.DebugPlayerImmortal; self.addToggleWidget( - GameMenu.Localize("Player immortal").AsHaxeString(), + LobbySession.Localize("Player immortal").AsHaxeString(), null, new HlFunc(ToggleDebugImmortalSetting), Ref.From(ref immortalNow), @@ -301,7 +301,7 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool explorersRuneNow = MultiplayerSettingsStorage.DebugUseExplorersRune; self.addToggleWidget( - GameMenu.Localize("Use Explorer's Rune").AsHaxeString(), + LobbySession.Localize("Use Explorer's Rune").AsHaxeString(), null, new HlFunc(ToggleDebugUseExplorersRuneSetting), Ref.From(ref explorersRuneNow), @@ -309,7 +309,7 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool mobsSyncTraceNow = MultiplayerSettingsStorage.DebugMobsSyncTrace; self.addToggleWidget( - GameMenu.Localize("Mobs sync trace logging").AsHaxeString(), + LobbySession.Localize("Mobs sync trace logging").AsHaxeString(), null, new HlFunc(ToggleDebugMobsSyncTraceSetting), Ref.From(ref mobsSyncTraceNow), @@ -317,7 +317,7 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool bossSyncTraceNow = MultiplayerSettingsStorage.DebugBossSyncTrace; self.addToggleWidget( - GameMenu.Localize("Boss sync trace logging").AsHaxeString(), + LobbySession.Localize("Boss sync trace logging").AsHaxeString(), null, new HlFunc(ToggleDebugBossSyncTraceSetting), Ref.From(ref bossSyncTraceNow), @@ -325,8 +325,8 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) bool showPerfLogsNow = MultiplayerSettingsStorage.ShowPerfLogs; self.addToggleWidget( - GameMenu.Localize("Show perf logs").AsHaxeString(), - GameMenu.Localize("Controls threshold-based [Perf] hitch and slowdown logging.").AsHaxeString(), + LobbySession.Localize("Show perf logs").AsHaxeString(), + LobbySession.Localize("Controls threshold-based [Perf] hitch and slowdown logging.").AsHaxeString(), new HlFunc(ToggleShowPerfLogsSetting), Ref.From(ref showPerfLogsNow), widgetParent); @@ -337,21 +337,21 @@ private void AddDebugSettingsWidgets(Options self, dc.h2d.Flow widgetParent) int leftPadding = 5; self.addSimpleWidget( - GameMenu.Localize("Start perk").AsHaxeString(), + LobbySession.Localize("Start perk").AsHaxeString(), selectedPerk.AsHaxeString(), new HlAction(() => { }), Ref.From(ref leftPadding), widgetParent); self.addSimpleWidget( - GameMenu.Localize("Previous perk").AsHaxeString(), + LobbySession.Localize("Previous perk").AsHaxeString(), null, new HlAction(() => CycleDebugStartPerk(self, -1)), Ref.From(ref leftPadding), widgetParent); self.addSimpleWidget( - GameMenu.Localize("Next perk").AsHaxeString(), + LobbySession.Localize("Next perk").AsHaxeString(), null, new HlAction(() => CycleDebugStartPerk(self, +1)), Ref.From(ref leftPadding), @@ -363,7 +363,7 @@ private static void AddSectionLabel(Options self, dc.h2d.Flow widgetParent, stri if (self == null || widgetParent == null || string.IsNullOrWhiteSpace(label)) return; - var localized = GameMenu.Localize(label).AsHaxeString(); + var localized = LobbySession.Localize(label).AsHaxeString(); try { // Native game header widget (centered title + separator line). @@ -493,7 +493,7 @@ private bool ToggleMobsSyncSetting() if (!enabled) { MobsSynchronization.ClearTrackingForLevelChange(); - try { GameMenu.NetRef?.ClearMobSyncQueues(); } catch { } + try { LobbySession.NetRef?.ClearMobSyncQueues(); } catch { } } return enabled; diff --git a/server/server.NetNode.Cleanup.cs b/server/server.NetNode.Cleanup.cs index 94c0321..480d304 100644 --- a/server/server.NetNode.Cleanup.cs +++ b/server/server.NetNode.Cleanup.cs @@ -44,10 +44,10 @@ private void CleanupClient() { CloseClientConnection(); } - GameMenu.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyRemoteDisconnected(_role); + LobbySession.NotifyRemoteDisconnected(_role); }); } diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index 608f225..1086152 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -535,6 +535,21 @@ public bool TryGetRemoteUsername(int userId, out string? username) } } + public bool TryGetRemoteSkin(int userId, out string? skin) + { + lock (_sync) + { + if (userId > 0 && _remotes.TryGetValue(userId, out var state) && state.HasRemote) + { + skin = state.Skin; + return !string.IsNullOrWhiteSpace(skin); + } + + skin = null; + return false; + } + } + public void CopyRemoteUserIdsTo(HashSet target, bool includePrimary = true) { if (target == null) diff --git a/server/server.NetNode.Handshake.cs b/server/server.NetNode.Handshake.cs index 273c3fb..fa77ed4 100644 --- a/server/server.NetNode.Handshake.cs +++ b/server/server.NetNode.Handshake.cs @@ -75,7 +75,7 @@ private bool RejectIncompatiblePeer(string line, string expectedTag) remoteProtocol, BuildInfo.Version, BuildInfo.NetworkProtocolVersion); - GameMenu.NotifyProtocolMismatch(remoteBuild, remoteProtocol, BuildInfo.Version, BuildInfo.NetworkProtocolVersion, _role); + LobbySession.NotifyProtocolMismatch(remoteBuild, remoteProtocol, BuildInfo.Version, BuildInfo.NetworkProtocolVersion, _role); return true; } @@ -110,12 +110,12 @@ private void CompleteHostHandshake(int senderId) _primaryRemoteId = senderId; } - GameMenu.EnqueueCriticalMainThreadCoalesced("net:remote-connected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-connected", () => { if (!IsCurrentNetworkSession()) return; - GameMenu.SetRole(_role); - GameMenu.NotifyRemoteConnected(_role); + LobbySession.SetRole(_role); + LobbySession.NotifyRemoteConnected(_role); }); } diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index 52cd623..08cda3d 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -418,12 +418,12 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) _log.Information("[NetNode] Assigned ID {Id}", ID); if (!_useSteamTransport) { - GameMenu.EnqueueCriticalMainThreadCoalesced("net:client-connected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:client-connected", () => { if (!IsCurrentNetworkSession()) return; - GameMenu.SetRole(_role); - GameMenu.NotifyRemoteConnected(_role); + LobbySession.SetRole(_role); + LobbySession.NotifyRemoteConnected(_role); }); } } @@ -474,7 +474,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.CommitTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLaunchCommitPayload(payload); + RunLaunchFlow.ReceiveRunLaunchCommitPayload(payload); return true; } @@ -482,7 +482,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.AckTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLaunchAckPayload(payload); + RunLaunchFlow.ReceiveRunLaunchAckPayload(payload); return true; } @@ -490,7 +490,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.ExecuteTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLaunchExecutePayload(payload); + RunLaunchFlow.ReceiveRunLaunchExecutePayload(payload); return true; } @@ -498,7 +498,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.QueuedTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLaunchQueuedPayload(payload); + RunLaunchFlow.ReceiveRunLaunchQueuedPayload(payload); return true; } @@ -506,7 +506,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.ReadyTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLevelReadyPayload(payload); + RunLaunchFlow.ReceiveRunLevelReadyPayload(payload); return true; } @@ -514,7 +514,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line[(RunLaunchWireCodec.CancelTag.Length + 1)..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveRunLaunchCancelPayload(payload); + RunLaunchFlow.ReceiveRunLaunchCancelPayload(payload); return true; } @@ -527,7 +527,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var launchKind = partsSeed[3]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveHostRunSeed(sequence, hostSeed, launchKind); + RunLaunchFlow.ReceiveHostRunSeed(sequence, hostSeed, launchKind); _log.Information( "[NetNode] Received host run seed seq={Sequence} seed={Seed} launch={LaunchKind}", sequence, @@ -547,7 +547,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) if (int.TryParse(payload, NumberStyles.Integer, CultureInfo.InvariantCulture, out var restartSeed)) { lock (_sync) _hasRemote = true; - GameMenu.ReceiveHostRunRestart(restartSeed); + RunLaunchFlow.ReceiveHostRunRestart(restartSeed); } else { @@ -615,7 +615,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (effectiveId.Value == primaryId) - GameMenu.ReceiveRemoteUsername(username); + LobbySession.ReceiveRemoteUsername(username); if (_role == NetRole.Host && senderId.HasValue) forwardLine = BuildTaggedLine("USER", effectiveId.Value, username); @@ -644,7 +644,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) _primaryRemoteId = effectiveId.Value; } - GameMenu.ReceiveRemoteReady(effectiveId.Value, ready); + ReadySync.ReceiveRemoteReady(effectiveId.Value, ready); if (_role == NetRole.Host && senderId.HasValue) forwardLine = BuildReadyLine(effectiveId.Value, ready); @@ -675,7 +675,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) _primaryRemoteId = effectiveId.Value; } - GameMenu.ReceiveRemoteCoopState(effectiveId.Value, coopId, hasContinueSave); + CoopIdentity.ReceiveRemoteCoopState(effectiveId.Value, coopId, hasContinueSave); if (_role == NetRole.Host && senderId.HasValue) forwardLine = BuildCoopStateLine(effectiveId.Value, coopId, hasContinueSave); @@ -700,7 +700,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) lock (_sync) _hasRemote = true; - GameMenu.ReceiveLaunchMode( + RunLaunchFlow.ReceiveLaunchMode( actionValue, custom, streamEnabled, @@ -749,7 +749,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line["LDESC|".Length..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveLevelDesc(payload); + LobbySession.ReceiveLevelDesc(payload); return true; } @@ -792,7 +792,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) var skinId = effectiveId.Value; var skinValue = skin; - GameMenu.EnqueueMainThreadCoalesced(string.Create(CultureInfo.InvariantCulture, $"net:skin:{skinId}"), () => + MainThreadPump.EnqueueMainThreadCoalesced(string.Create(CultureInfo.InvariantCulture, $"net:skin:{skinId}"), () => { if (!IsCurrentNetworkSession()) return; @@ -837,7 +837,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) var headId = effectiveId.Value; var headSkinValue = skinHead; - GameMenu.EnqueueMainThreadCoalesced(string.Create(CultureInfo.InvariantCulture, $"net:head:{headId}"), () => + MainThreadPump.EnqueueMainThreadCoalesced(string.Create(CultureInfo.InvariantCulture, $"net:head:{headId}"), () => { if (!IsCurrentNetworkSession()) return; @@ -863,7 +863,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line["GEN|".Length..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveGeneratePayload(payload); + LobbySession.ReceiveGeneratePayload(payload); return true; } @@ -871,7 +871,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) { var payload = line["CGDATA|".Length..]; lock (_sync) _hasRemote = true; - GameMenu.ReceiveCustomGameData(payload); + RunLaunchFlow.ReceiveCustomGameData(payload); return true; } diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index 2e4d753..0c5fb92 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -181,10 +181,10 @@ private void StartSteamClient() { try { _cts.Cancel(); } catch { } _log.Warning("[NetNode] Steam P2P client failed to start: {Error}", error); - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-failed", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-failed", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectFailed(); + LobbySession.NotifyClientConnectFailed(); }); return; } @@ -257,17 +257,17 @@ private void TrySendTcpKeepAlive() private async Task ConnectWithRetrySteamBridgeAsync(CancellationToken ct) { - var maxAttempts = GameMenu.ClientConnectMaxAttempts; + var maxAttempts = LobbySession.ClientConnectMaxAttempts; var attempt = 0; var bridge = _steamBridge; if (_steamHostId.m_SteamID == 0UL || bridge == null) { _log.Warning("[NetNode] Steam client host id or bridge is missing"); - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-failed", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-failed", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectFailed(); + LobbySession.NotifyClientConnectFailed(); }); return; } @@ -278,10 +278,10 @@ private async Task ConnectWithRetrySteamBridgeAsync(CancellationToken ct) "[NetNode] Steam P2P requires two different Steam accounts. Host and client both use SteamId={SteamId}. " + "Use a second Steam account (e.g. family sharing or another PC) to test multiplayer.", _steamHostId.m_SteamID); - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-failed", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-failed", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectFailed(); + LobbySession.NotifyClientConnectFailed(); }); return; } @@ -289,10 +289,10 @@ private async Task ConnectWithRetrySteamBridgeAsync(CancellationToken ct) while (!ct.IsCancellationRequested && attempt < maxAttempts) { attempt++; - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-attempt", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-attempt", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectAttempt(attempt); + LobbySession.NotifyClientConnectAttempt(attempt); }); _log.Information("[NetNode] Steam client connecting to hostSteamId={HostSteamId}", _steamHostId.m_SteamID); @@ -318,12 +318,12 @@ private async Task ConnectWithRetrySteamBridgeAsync(CancellationToken ct) if (connected) { - GameMenu.EnqueueCriticalMainThreadCoalesced("net:remote-connected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-connected", () => { if (!IsCurrentNetworkSession()) return; - GameMenu.SetRole(_role); - GameMenu.NotifyRemoteConnected(_role); + LobbySession.SetRole(_role); + LobbySession.NotifyRemoteConnected(_role); }); return; } @@ -339,10 +339,10 @@ private async Task ConnectWithRetrySteamBridgeAsync(CancellationToken ct) "[NetNode] Steam client connection failed: no WELCOME/ID received within 6s after HELLO (attempt {Attempt}/{Max})", attempt, maxAttempts); - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-failed", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-failed", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectFailed(); + LobbySession.NotifyClientConnectFailed(); }); break; } @@ -450,7 +450,7 @@ await ProcessIncomingSteamPayloadAsync(entry.Payload, entry.SenderId, entry.Conn return; } - GameMenu.EnqueueMainThreadCoalesced("net:cleanup-client", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:cleanup-client", () => { if (IsCurrentNetworkSession()) CleanupClient(); @@ -491,7 +491,7 @@ await ProcessIncomingSteamPayloadAsync(entry.Payload, entry.SenderId, entry.Conn return; } - GameMenu.EnqueueMainThreadCoalesced( + MainThreadPump.EnqueueMainThreadCoalesced( string.Create(System.Globalization.CultureInfo.InvariantCulture, $"net:cleanup-host-client:{connToCleanup.AssignedId}"), () => { @@ -525,7 +525,7 @@ await ProcessIncomingSteamPayloadAsync(entry.Payload, entry.SenderId, entry.Conn "[NetNode][Steam] client receive timeout after {Elapsed:F1}s (limit {Limit:F1}s) - closing session", elapsed, SteamReceiveTimeoutSeconds); - GameMenu.EnqueueMainThreadCoalesced("net:cleanup-client", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:cleanup-client", () => { if (IsCurrentNetworkSession()) CleanupClient(); @@ -782,7 +782,7 @@ private async Task ProcessIncomingSteamPayloadAsync( var accepted = false; try { - await GameMenu.EnqueueNetworkMainThreadAsync(() => + await MainThreadPump.EnqueueNetworkMainThreadAsync(() => { try { @@ -882,10 +882,10 @@ private void CleanupHostSteamClient(SteamClientConnection sender) } if (wasConnected && !hasClients) - GameMenu.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyRemoteDisconnected(_role); + LobbySession.NotifyRemoteDisconnected(_role); }); } } diff --git a/server/server.Tcp.cs b/server/server.Tcp.cs index 6ef5cbf..b8a0552 100644 --- a/server/server.Tcp.cs +++ b/server/server.Tcp.cs @@ -62,16 +62,16 @@ private void ConfigureTcpSocketKeepAlive(TcpClient tcp) private async Task ConnectWithRetryAsync(CancellationToken ct) { - var maxAttempts = GameMenu.ClientConnectMaxAttempts; + var maxAttempts = LobbySession.ClientConnectMaxAttempts; var attempt = 0; while (!ct.IsCancellationRequested && attempt < maxAttempts) { attempt++; - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-attempt", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-attempt", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectAttempt(attempt); + LobbySession.NotifyClientConnectAttempt(attempt); }); try { @@ -104,10 +104,10 @@ private async Task ConnectWithRetryAsync(CancellationToken ct) _log.Warning("[NetNode] Client connect error: {msg}", ex.Message); if (attempt >= maxAttempts) { - GameMenu.EnqueueMainThreadCoalesced("net:client-connect-failed", () => + MainThreadPump.EnqueueMainThreadCoalesced("net:client-connect-failed", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyClientConnectFailed(); + LobbySession.NotifyClientConnectFailed(); }); break; } @@ -373,7 +373,7 @@ private async Task ProcessIncomingLinesLoop( { var lineCopy = line; - await GameMenu.EnqueueNetworkMainThreadAsync(() => + await MainThreadPump.EnqueueNetworkMainThreadAsync(() => { if (!IsCurrentNetworkSession()) return; @@ -464,10 +464,10 @@ private void CleanupHostClient(ClientConnection sender) stillNoCompletedClients = CountCompletedHostClientsLocked() == 0; } if (stillNoCompletedClients) - GameMenu.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => { if (IsCurrentNetworkSession()) - GameMenu.NotifyRemoteDisconnected(_role); + LobbySession.NotifyRemoteDisconnected(_role); }); } } diff --git a/server/server.cs b/server/server.cs index 31a3e8d..e73dac4 100644 --- a/server/server.cs +++ b/server/server.cs @@ -639,7 +639,7 @@ public static int ConnectedClientCount { get { - var active = GameMenu.NetRef; + var active = LobbySession.NetRef; return active == null || active._disposed ? 0 : Volatile.Read(ref active._connectedClientCount); @@ -672,12 +672,12 @@ private bool TryTakeNextUnusedClientId(out int assignedId) private bool IsCurrentNetworkSession() { - return !_disposed && ReferenceEquals(GameMenu.NetRef, this); + return !_disposed && ReferenceEquals(LobbySession.NetRef, this); } private bool IsSupersededNetworkSession() { - var active = GameMenu.NetRef; + var active = LobbySession.NetRef; return _disposed || (active != null && !ReferenceEquals(active, this)); }