diff --git a/AdvancedCoop/CoopAdvancedHardening.cs b/AdvancedCoop/CoopAdvancedHardening.cs index db8e9ae..0ee587a 100644 --- a/AdvancedCoop/CoopAdvancedHardening.cs +++ b/AdvancedCoop/CoopAdvancedHardening.cs @@ -165,8 +165,8 @@ public static void ReceiveLobbyState(string payload) if (string.IsNullOrWhiteSpace(payload)) return; - // Current build uses this mainly as a live-room heartbeat. The menu/connection UI already reads NetNode.HasRemote; - // receiving this packet marks the remote alive in NetNode. Keep parsing intentionally loose for forward compatibility. + // Live-room heartbeat only. Username updates are applied when the value actually changes + // (ReceiveRemoteUsername is change-gated) so this path must not spam logs/UI refreshes. try { var parts = payload.Split('|'); diff --git a/GameDataSync/GameDataSync.cs b/GameDataSync/GameDataSync.cs index 5903263..53ecefb 100644 --- a/GameDataSync/GameDataSync.cs +++ b/GameDataSync/GameDataSync.cs @@ -38,6 +38,9 @@ internal partial class GameDataSync : IEventReceiver, IOnAdvancedModuleInitializ static public bool _mode; static public LaunchMode _launch = default!; + private static readonly object _sameRunRestartSync = new(); + private static bool _sameRunRestartPending; + private static int _sameRunRestartSeed; private static readonly object _bossRuneLock = new(); private static int? _remoteBossRune; private static int? _hostBossRune; @@ -143,19 +146,55 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, bool mode, LaunchMode gdata) { - isCustom = false; - mode = false; - Seed = lvl; + var sameRunRestart = TryPeekSameRunRestartSeed(out var restartSeed); + var effectiveStreamEnabled = isCustom; + var effectiveCustomMode = mode; + var effectiveLaunch = gdata; + if (gdata is LaunchMode.NewGame newGame) + { + if (sameRunRestart) + { + effectiveCustomMode = ResolveCurrentRunIsCustom(); + effectiveStreamEnabled = ResolveCurrentRunStreamEnabled(); + effectiveLaunch = new LaunchMode.NewGame(effectiveCustomMode, effectiveStreamEnabled); + } + else if (GameMenu.TryGetAuthoritativePendingNewGameLaunch(out var selectedCustom, out var selectedStreamEnabled)) + { + effectiveCustomMode = selectedCustom; + effectiveStreamEnabled = selectedStreamEnabled; + effectiveLaunch = new LaunchMode.NewGame(selectedCustom, selectedStreamEnabled); + } + else + { + // Keep Normal Mode multiplayer runs deterministic unless Custom Mode + // explicitly authored a pending launch. + effectiveCustomMode = false; + effectiveStreamEnabled = false; + } + } + + isCustom = effectiveStreamEnabled; + mode = effectiveCustomMode; + gdata = effectiveLaunch; + + Seed = sameRunRestart ? restartSeed : lvl; ModEntry.me = null!; ModEntry.ResetClientSlots(); ModEntry.kingInitialized = false; ModEntry._ghost = null!; + ModEntry.ResetHeroCosmeticSendCache(); + ClearPendingBossRuneReloadState(); + _lastHeroSkinSyncNet = null; + _lastHeroSkinSyncPayload = null; + _lastHeroHeadSkinSyncNet = null; + _lastHeroHeadSkinSyncPayload = null; var net = GameMenu.NetRef; var launchKind = GetLaunchKind(gdata); var nativeBossRushLaunch = IsBossRushLaunchKind(launchKind); - var expectedBossRushLaunch = nativeBossRushLaunch || - (net?.IsHost == true && GameMenu.HasPrecommittedHostBossRushLaunch()) || - (net != null && !net.IsHost && GameMenu.HasPendingRemoteBossRushLaunch()); + var expectedBossRushLaunch = !sameRunRestart && + (nativeBossRushLaunch || + (net?.IsHost == true && GameMenu.HasPrecommittedHostBossRushLaunch()) || + (net != null && !net.IsHost && GameMenu.HasPendingRemoteBossRushLaunch())); if (expectedBossRushLaunch && !nativeBossRushLaunch) { // Some generated bindings expose the Boss Rush launch variant with a runtime name @@ -163,7 +202,7 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, // stronger signal than the local type name, so normalize the wire identity here. launchKind = "dc.LaunchMode+BossRush"; } - var shouldSynchronizeSeed = gdata is LaunchMode.NewGame || expectedBossRushLaunch; + var shouldSynchronizeSeed = !sameRunRestart && (gdata is LaunchMode.NewGame || expectedBossRushLaunch); if (net == null || !net.IsAlive) RestoreOriginalUserState(self, true); @@ -171,7 +210,11 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, { var reusedPrecommittedSeed = false; var seedSequence = 0; - if (shouldSynchronizeSeed && + if (sameRunRestart) + { + Seed = restartSeed; + } + else if (shouldSynchronizeSeed && GameMenu.TryConsumePrecommittedHostRunSeed(launchKind, out var precommittedSeed, out var precommittedSequence)) { Seed = precommittedSeed; @@ -210,7 +253,11 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, } else if (net != null) { - if (shouldSynchronizeSeed && + if (sameRunRestart) + { + Seed = restartSeed; + } + else if (shouldSynchronizeSeed && GameMenu.TryConsumeNextRemoteRunSeed(out var remoteSeed, out var remoteSequence, out var remoteLaunchKind)) { Seed = remoteSeed; @@ -291,7 +338,29 @@ public static void user_hook_new_game(Hook_User.orig_newGame orig, self.pickDeathItem(); SendHeroSkin(self, net); SendHeroHeadSkin(self, net); - orig(self, lvl, isTwitch, isCustom, mode, gdata); + if (mode) + { + // 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)) + { + _log?.Warning( + "[NetMod] Custom Mode newGame: User.itemMeta could not be prepared (role={Role})", + net?.IsHost == true ? "host" : "client"); + } + } + + try + { + orig(self, lvl, isTwitch, isCustom, mode, gdata); + } + finally + { + GameMenu.ClearAuthoritativePendingNewGameLaunch(); + if (sameRunRestart) + ClearSameRunRestart(); + } } @@ -304,6 +373,12 @@ private static bool ShouldSynchronizeRunSeed(LaunchMode? launch) // unconsumed nested seed; that restart is now suppressed for Boss Rush kinds in // GameMenu.ReceiveHostRunSeed, so sharing the seed is safe. Challenge rooms, daily // modes and other nested launches stay local. + lock (_sameRunRestartSync) + { + if (_sameRunRestartPending) + return false; + } + return launch is LaunchMode.NewGame || IsBossRushLaunch(launch); } @@ -345,6 +420,92 @@ private static string GetLaunchKind(LaunchMode? launch) public static void MarkProgressPayloadDirty() { } + internal static void BeginSameRunRestart(int seed) + { + lock (_sameRunRestartSync) + { + _sameRunRestartPending = true; + _sameRunRestartSeed = seed; + } + } + + private static bool TryPeekSameRunRestartSeed(out int seed) + { + lock (_sameRunRestartSync) + { + if (_sameRunRestartPending) + { + seed = _sameRunRestartSeed; + return true; + } + } + + seed = 0; + return false; + } + + private static void ClearSameRunRestart() + { + lock (_sameRunRestartSync) + { + _sameRunRestartPending = false; + _sameRunRestartSeed = 0; + } + } + + internal static void CancelSameRunRestart() + { + ClearSameRunRestart(); + } + + internal static bool ResolveCurrentRunIsCustom() + { + try + { + var currentCustomGame = dc.pr.Game.Class.ME?.data?.cgData; + if (currentCustomGame != null) + return true; + } + catch + { + } + + try + { + var mainGameData = dc.Main.Class.ME?.user?.mainGameData; + if (mainGameData != null) + return mainGameData.isCustom; + } + catch + { + } + + return _isCustom; + } + + internal static bool ResolveCurrentRunStreamEnabled() + { + try + { + var game = dc.pr.Game.Class.ME; + if (game?.data != null) + return game.data._twitchMode; + } + catch + { + } + + if (_launch is LaunchMode.NewGame storedNewGame) + return storedNewGame.Param1; + + return false; + } + + internal static LaunchMode BuildSameRunRestartLaunchMode() + { + return new LaunchMode.NewGame(ResolveCurrentRunIsCustom(), ResolveCurrentRunStreamEnabled()); + } + private static string? GetCurrentProgressPayload(User user) { if (user == null) @@ -886,6 +1047,31 @@ public static bool TryGetRemoteBossRune(out int bossRune) return false; } + internal static bool HasRemoteBossRune() + { + lock (_bossRuneLock) + { + return _remoteBossRune.HasValue; + } + } + + internal static void SendCurrentHeroCosmetics(User? user, NetNode? net, bool force = false) + { + if (user == null || net == null || !net.IsAlive) + return; + + if (force) + { + _lastHeroSkinSyncNet = null; + _lastHeroSkinSyncPayload = null; + _lastHeroHeadSkinSyncNet = null; + _lastHeroHeadSkinSyncPayload = null; + } + + SendHeroSkin(user, net); + SendHeroHeadSkin(user, net); + } + public static void SaveOrigHpMultipliers() { if (_origHpMultipliersSaved) diff --git a/Ghost/GhostHero.cs b/Ghost/GhostHero.cs index d5def6b..0221e8a 100644 --- a/Ghost/GhostHero.cs +++ b/Ghost/GhostHero.cs @@ -100,64 +100,153 @@ public GhostKing CreateGhostKing(Level level, string? label = null) public void disposeKing(GhostKing k) { - if (k.spr != null) - { - ColorMap shader = (ColorMap)k.spr.getShader(ColorMap.Class); + if (k == null) + return; - if (shader != null) - { - k.spr.removeShader(shader); - k.spr.lib = null; - } + if (ReferenceEquals(king, k)) + king = null!; + + try + { + if (_labels.ContainsKey(k)) + _labels.Remove(k); + } + catch + { } - if (k.spriteClones != null) + DisposeKingRuntime(k); + } + + public static void DisposeKingRuntime(GhostKing k) + { + if (k == null) + return; + + List? failures = null; + try { - int num = 0; - ArrayObj arrayObj = k.spriteClones; - for (; ; ) + Level? level = null; + try { level = k._level; } catch { } + + try { - int length = arrayObj.length; - if (num >= length) - { - break; - } - length = arrayObj.length; - virtual_e_followHead_notActualClone_offX_offY_scaleBonus_? virtual_e_followHead_notActualClone_offX_offY_scaleBonus_; - if (num >= length) - { - virtual_e_followHead_notActualClone_offX_offY_scaleBonus_ = null; - } - else - { - virtual_e_followHead_notActualClone_offX_offY_scaleBonus_ = (virtual_e_followHead_notActualClone_offX_offY_scaleBonus_)arrayObj.array[num]!; - } - num++; - HSprite hsprite = virtual_e_followHead_notActualClone_offX_offY_scaleBonus_!.e; - if (hsprite != null) + if (k.spr != null) { - if (hsprite.parent != null) + ColorMap shader = (ColorMap)k.spr.getShader(ColorMap.Class); + if (shader != null) { - hsprite.parent.removeChild(hsprite); + k.spr.removeShader(shader); + k.spr.lib = null; } } } + catch (Exception ex) + { + failures ??= new List(); + failures.Add(ex); + } + + try + { + if (!k.destroyed) + k.destroy(); + } + catch (Exception ex) + { + failures ??= new List(); + failures.Add(ex); + } + + if (level != null) + { + try { level.runEntitiesGC(); } catch { } + try { RemoveKingFromLevelCollections(level, k); } catch { } + } + else + { + try { k.dispose(); } catch { } + } } + catch (Exception ex) + { + failures ??= new List(); + failures.Add(ex); + } + + if (failures != null && failures.Count > 0) + _log?.Warning("[NetMod] GhostKing dispose reported {Count} step failure(s)", failures.Count); + } - if (k.speechSfxDeck != null) + private static void RemoveKingFromLevelCollections(Level level, GhostKing k) + { + level.entities?.remove(k); + level.qTreeEntities?.remove(k); + level.savedEntities?.remove(k); + level.entitiesGC?.remove(k); + + ArrayBytes_Int? clids = null; + try { clids = k.getEntityCLIDS(); } catch { } + if (clids == null || level.entitiesByClass == null) + return; + + for (var i = 0; i < clids.length; i++) { - k.speechSfxDeck.clear(); + var entries = level.entitiesByClass.get(clids.getDyn(i)) as ArrayObj; + entries?.remove(k); } + } + + /// + /// Strip any GhostKing instances from a level's entity collections so they cannot leak into + /// MSave / Continue. Safe to call during level create and immediately before writeSave. + /// + public static int PurgeGhostKingsFromLevel(Level? level) + { + if (level == null) + return 0; + + var ghosts = new HashSet(); + CollectGhostKings(level.entities, ghosts); + CollectGhostKings(level.qTreeEntities, ghosts); + CollectGhostKings(level.savedEntities, ghosts); + CollectGhostKings(level.entitiesGC, ghosts); + + foreach (var ghost in ghosts) + DisposeKingRuntime(ghost); - if (k.runAnims != null) + return ghosts.Count; + } + + public static int PurgeGhostKingsFromCurrentGame() + { + try + { + Level? level = null; + try { level = ModEntry.me?._level; } catch { } + if (level == null) + { + try { level = ModEntry.Instance?.game?.curLevel; } catch { } + } + + return PurgeGhostKingsFromLevel(level); + } + catch { - k.runAnims = null; + return 0; } - k.removeAllLights(true); - k.disposeGfx(); - k.destroy(); - k.dispose(); + } + + private static void CollectGhostKings(ArrayObj? entries, HashSet ghosts) + { + if (entries == null) + return; + for (var i = 0; i < entries.length; i++) + { + if (entries.getDyn(i) is GhostKing ghost) + ghosts.Add(ghost); + } } public void SetLabel(Entity entity, string? text) diff --git a/Ghost/GhostKing.cs b/Ghost/GhostKing.cs index 0240378..15cf200 100644 --- a/Ghost/GhostKing.cs +++ b/Ghost/GhostKing.cs @@ -62,13 +62,71 @@ void IHxbitSerializable.SetData(object data) { } + /// + /// Multiplayer GhostKing is a live network avatar only. Persisting it into MSave makes + /// LoadSave/Continue crash in KingSkin.initGfx (null cd.fastCheck) because Hxbit restore + /// cannot rebuild our runtime-only fields. + /// + public override bool shouldSave() + { + return false; + } + + public override bool prepareSave() + { + return false; + } + + public override void onReload() + { + // Never revive a GhostKing from save data — destroy the husk instead of initGfx. + try + { + if (!destroyed) + destroy(); + } + catch + { + } + } + public override void init() { + if (IsUnsafePersistedInstance()) + { + try + { + if (!destroyed) + destroy(); + } + catch + { + } + return; + } + EnsureRuntimeDependencies(); base.init(); base.initSpeechDeck(); } + private bool IsUnsafePersistedInstance() + { + try + { + // Fresh multiplayer kings always have a cooldown map after Entity construction / + // EnsureRuntimeDependencies. A Hxbit-restored husk typically has cd == null or a + // cooldown without fastCheck, which is exactly what crashes KingSkin.initGfx. + if (cd == null) + return true; + return cd.fastCheck == null; + } + catch + { + return true; + } + } + private static Hero? ResolveLocalHero() { var hero = ModEntry.me; @@ -1029,6 +1087,9 @@ private void DisposeKingWeaponsManager() public override void initGfx() { + if (IsUnsafePersistedInstance()) + return; + base.initGfx(); var skinInfo = ResolveBodySkinInfo(RemoteSkinId ?? ModEntry.Instance?.remoteSkin, out var resolvedSkinId); if (skinInfo == null) diff --git a/LevelSync.cs b/LevelSync.cs index 23665e9..4f4820d 100644 --- a/LevelSync.cs +++ b/LevelSync.cs @@ -246,6 +246,17 @@ internal static void ClearPendingBossRuneReloadState() } } + internal static bool HasPendingRemoteLevelGraph(string? levelId) + { + if (string.IsNullOrWhiteSpace(levelId)) + return false; + + lock (_levelGraphLock) + { + return _remoteLevelGraphs.ContainsKey(levelId); + } + } + private static void TryScheduleBossRuneReloadForLevel(string levelId) { if (string.IsNullOrWhiteSpace(levelId)) diff --git a/MUser.cs b/MUser.cs new file mode 100644 index 0000000..08ddcb1 --- /dev/null +++ b/MUser.cs @@ -0,0 +1,306 @@ +using System.Globalization; +using Newtonsoft.Json; +using IOFile = System.IO.File; +using IODirectory = System.IO.Directory; +using IOPath = System.IO.Path; + +namespace DeadCellsMultiplayerMod +{ + internal static class MUser + { + private const int CurrentVersion = 1; + private const int MaxCoopIdLength = 128; + private const string MultiplayerSaveFolderName = "MSave"; + private const string MetadataExtension = ".coop.json"; + private static readonly object Sync = new(); + private static int _cachedSlot = -1; + private static bool _cachedPathExists; + private static DateTime _cachedWriteUtc; + private static CoopMetadata? _cachedMetadata; + + public static string? GetCurrentCoopId() + { + return GetCoopIdForSlot(null); + } + + public static string? GetCoopIdForSlot(int? slot) + { + return TryLoad(slot, out var metadata) + ? NormalizeCoopId(metadata.CoopId) + : null; + } + + public static string EnsureCoopIdForNewCoopWorld(string? lastHostId = null, int? lastSeed = null) + { + var coopId = Guid.NewGuid().ToString("N"); + SetCoopId(coopId, lastHostId, lastSeed); + return coopId; + } + + public static bool UpdateCoopRunSeed(int? lastSeed, string? lastHostId = null) + { + var coopId = GetCurrentCoopId(); + return !string.IsNullOrWhiteSpace(coopId) && SetCoopId(coopId, lastHostId, lastSeed); + } + + public static bool SetCoopId(string? coopId, string? lastHostId = null, int? lastSeed = null) + { + var normalized = NormalizeCoopId(coopId); + if (string.IsNullOrWhiteSpace(normalized)) + return false; + + lock (Sync) + { + try + { + var slot = ResolveSaveSlotNumber(null); + var path = GetMetadataPathForSlot(slot); + var normalizedHostId = NormalizeMetadataValue(lastHostId); + var now = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var createdAt = now; + + if (TryLoad(slot, out var existing)) + { + if (string.Equals(NormalizeCoopId(existing.CoopId), normalized, StringComparison.Ordinal) && + string.Equals(existing.LastHostId, normalizedHostId, StringComparison.Ordinal) && + existing.LastSeed == lastSeed && + !string.IsNullOrWhiteSpace(existing.CreatedAtUtc)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(existing.CreatedAtUtc)) + createdAt = existing.CreatedAtUtc; + } + + var metadata = new CoopMetadata + { + Version = CurrentVersion, + CoopId = normalized, + LastHostId = normalizedHostId, + LastSeed = lastSeed, + CreatedAtUtc = createdAt, + UpdatedAtUtc = now + }; + + IODirectory.CreateDirectory(IOPath.GetDirectoryName(path)!); + IOFile.WriteAllText(path, JsonConvert.SerializeObject(metadata, Formatting.Indented)); + UpdateCacheLocked(slot, path, metadata); + return true; + } + catch + { + return false; + } + } + } + + public static void ClearCoopId(int? slot = null) + { + lock (Sync) + { + try + { + var resolvedSlot = ResolveSaveSlotNumber(slot); + var path = GetMetadataPathForSlot(resolvedSlot); + if (IOFile.Exists(path)) + IOFile.Delete(path); + + InvalidateCacheLocked(resolvedSlot); + } + catch + { + } + } + } + + public static bool IsContinueCompatible(string? remoteCoopId, out string reason) + { + var localCoopId = GetCurrentCoopId(); + if (string.IsNullOrWhiteSpace(localCoopId)) + { + reason = "No local coop id"; + return false; + } + + var normalizedRemote = NormalizeCoopId(remoteCoopId); + if (string.IsNullOrWhiteSpace(normalizedRemote)) + { + reason = "Host coop id not received"; + return false; + } + + if (!string.Equals(localCoopId, normalizedRemote, StringComparison.Ordinal)) + { + reason = "Coop world mismatch"; + return false; + } + + reason = "OK"; + return true; + } + + public static string? NormalizeCoopId(string? coopId) + { + if (string.IsNullOrWhiteSpace(coopId)) + return null; + + var trimmed = coopId.Trim(); + if (trimmed.Length > MaxCoopIdLength) + return null; + + for (var i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') + continue; + + return null; + } + + return trimmed; + } + + private static bool TryLoad(int? slot, out CoopMetadata metadata) + { + metadata = new CoopMetadata(); + lock (Sync) + { + try + { + var resolvedSlot = ResolveSaveSlotNumber(slot); + var path = GetMetadataPathForSlot(resolvedSlot); + var exists = IOFile.Exists(path); + var writeUtc = exists ? IOFile.GetLastWriteTimeUtc(path) : DateTime.MinValue; + + if (_cachedSlot == resolvedSlot && + _cachedPathExists == exists && + _cachedWriteUtc == writeUtc) + { + if (_cachedMetadata == null) + return false; + + metadata = _cachedMetadata; + return true; + } + + if (!exists) + { + UpdateCacheLocked(resolvedSlot, path, null); + return false; + } + + var loaded = JsonConvert.DeserializeObject(IOFile.ReadAllText(path)); + if (loaded == null || string.IsNullOrWhiteSpace(NormalizeCoopId(loaded.CoopId))) + { + UpdateCacheLocked(resolvedSlot, path, null); + return false; + } + + metadata = loaded; + UpdateCacheLocked(resolvedSlot, path, loaded); + return true; + } + catch + { + return false; + } + } + } + + private static void UpdateCacheLocked(int slot, string path, CoopMetadata? metadata) + { + _cachedSlot = slot; + _cachedPathExists = metadata != null && IOFile.Exists(path); + _cachedWriteUtc = _cachedPathExists ? IOFile.GetLastWriteTimeUtc(path) : DateTime.MinValue; + _cachedMetadata = metadata; + } + + private static void InvalidateCacheLocked(int slot) + { + if (_cachedSlot != slot) + return; + + _cachedSlot = -1; + _cachedPathExists = false; + _cachedWriteUtc = DateTime.MinValue; + _cachedMetadata = null; + } + + private static string GetMetadataPathForSlot(int slot) + { + var fileName = string.Create( + CultureInfo.InvariantCulture, + $"user_{slot}{MetadataExtension}"); + return IOPath.Combine(GetMultiplayerSaveFolderPath(), fileName); + } + + private static string GetMultiplayerSaveFolderPath() + { + return IOPath.Combine(GetSaveRootPath(), MultiplayerSaveFolderName); + } + + private static int ResolveSaveSlotNumber(int? slot) + { + if (slot.HasValue && slot.Value >= 0) + return slot.Value; + + try + { + var current = dc.Main.Class.ME?.options?.curSlot; + if (current.HasValue && current.Value >= 0) + return current.Value; + } + catch + { + } + + return 0; + } + + private static string GetSaveRootPath() + { + try + { + var saveRoot = dc.tool.File.Class.PATH?.ToString(); + if (!string.IsNullOrWhiteSpace(saveRoot)) + return IOPath.GetFullPath(saveRoot); + } + catch + { + } + + try + { + return IOPath.GetFullPath("save"); + } + catch + { + return IOPath.Combine(Environment.CurrentDirectory, "save"); + } + } + + private static string? NormalizeMetadataValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var normalized = value.Trim() + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); + + return normalized.Length > 128 ? normalized[..128] : normalized; + } + + private sealed class CoopMetadata + { + public int Version { get; set; } = CurrentVersion; + public string CoopId { get; set; } = string.Empty; + public string? LastHostId { get; set; } + public int? LastSeed { get; set; } + public string CreatedAtUtc { get; set; } = string.Empty; + public string UpdatedAtUtc { get; set; } = string.Empty; + } + } +} diff --git a/Mobs/Levelinit.cs b/Mobs/Levelinit.cs index e40137a..11cc3d7 100644 --- a/Mobs/Levelinit.cs +++ b/Mobs/Levelinit.cs @@ -49,6 +49,16 @@ public override void Initialize() private void Levelinit_EntitiesPostCreate(Hook_Level.orig_entitiesPostCreate orig, Level self) { orig(self); + try + { + var purged = GhostHero.PurgeGhostKingsFromLevel(self); + if (purged > 0) + ModEntry.Instance?.Logger.Information("[NetMod] Purged {Count} GhostKing(s) after level create", purged); + } + catch (Exception ex) + { + ModEntry.Instance?.Logger.Warning("[NetMod] GhostKing purge after level create failed: {Message}", ex.Message); + } } private void Levelinit_OnDispose(Hook_Level.orig_onDispose orig, Level self) diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index 46a7e67..b041226 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -342,6 +342,13 @@ private void RegisterLocalDoorMarker(string? levelId, int markerToken) double last_x, last_y; int lastDir; + private void ResetLocalHeroPositionSendCache() + { + last_x = 0; + last_y = 0; + lastDir = 0; + } + private void SendHeroCoords() { if (_netRole == NetRole.None) return; @@ -690,8 +697,6 @@ private bool ShouldKeepRemoteKingVisibleInRoom(NetNode.RemoteSnapshot remote, st return true; // After revive, allow a short marker-settle window and force the existing shell visible. - // Without this, the stale environmental-death marker can immediately dispose the player - // again until a sublevel transition rebuilds all remote entities. if (IsRemoteReviveVisibilityGraceActive(remote.Id)) return true; @@ -702,27 +707,9 @@ private bool ShouldKeepRemoteKingVisibleInRoom(NetNode.RemoteSnapshot remote, st return false; } - if (!remote.HasRoom || - !remote.RoomId.HasValue || - remote.RoomId.Value < 0 || - string.IsNullOrWhiteSpace(remote.RoomLevelId)) - { - return true; - } - - if (!TryGetCurrentVisibilityContext(out var localContextLevelId, out var localBranchToken)) - { - localContextLevelId = localLevelId; - localBranchToken = _localLastDoorMarkerToken >= 0 ? _localLastDoorMarkerToken : 0; - } - - var remoteContextLevelId = remote.RoomLevelId.Trim(); - if (!string.Equals(remoteContextLevelId, localContextLevelId, StringComparison.Ordinal)) - return false; - - if (remote.RoomId.Value != localBranchToken) - return false; - + // Room marker replication is noisy around Continue/LoadSave and level bootstrap and can + // briefly diverge even when both players share the same map. Prefer level-only + // visibility so a fresh GhostKing can spawn after continue instead of being disposed. return true; } @@ -806,8 +793,27 @@ private void CancelPendingClientDispose(int slot) if (_ghost == null || me == null || me._level == null) return null; - var created = _ghost.CreateGhostKing(me._level); + GhostKing created; + try + { + created = _ghost.CreateGhostKing(me._level); + } + catch (Exception ex) + { + Logger.Warning( + "[NetMod] Failed to create remote GhostKing slot={Slot} remoteId={RemoteId}: {Message}", + slot, + clientIds[slot], + ex.Message); + return null; + } + clients[slot] = created; + Logger.Information( + "[NetMod] Created remote GhostKing slot={Slot} remoteId={RemoteId} level={LevelId}", + slot, + clientIds[slot], + me._level.map?.id?.ToString() ?? "?"); var knownSkin = clientSkins[slot]; if (!string.IsNullOrWhiteSpace(knownSkin)) @@ -1513,6 +1519,69 @@ private static void TryRemoveSupersededRemoteWeapon(Inventory inv, InventItem? s } } + private void DisposeCoopGhostRuntime() + { + try + { + ResetFakeDeathState(unlockLocalHero: false, sendNetworkUpState: false); + } + catch + { + } + + for (int i = 0; i < clients.Length; i++) + { + try + { + DisposeClientSlot(i, clearIdentity: true); + } + catch + { + } + } + + var ghost = _ghost; + _ghost = null!; + _ghostOwnerHero = null; + _ghostOwnerGame = null; + _ghostBootstrapNet = null; + _ = ghost; + } + + internal void DisposeCoopGhostRuntimeForWorldTeardown(dc.pr.Game? disposingGame = null) + { + _ = disposingGame; + DisposeCoopGhostRuntime(); + } + + internal void HandleNetworkDisconnectGhostCleanup(NetRole role) + { + if (role == NetRole.Host) + { + var activeRemoteIds = new HashSet(); + try { _net?.CopyRemoteUserIdsTo(activeRemoteIds, includePrimary: true); } catch { } + + for (int i = 0; i < clientIds.Length; i++) + { + var remoteId = clientIds[i]; + if (remoteId <= 0 || activeRemoteIds.Contains(remoteId)) + continue; + + try + { + DisposeClientSlot(i, clearIdentity: true); + } + catch + { + } + } + + return; + } + + DisposeCoopGhostRuntime(); + } + private static void ApplyRemoteWeaponAmmo(InventItem item, int? ammo) { if(item == null || !ammo.HasValue) diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 7ce5d0b..517cc1b 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -114,6 +114,9 @@ internal static void MarkSteamUnavailable(string reason) private static long[] clientNextHeadRecreateTick = new long[NetNode.MaxClientSlots]; public static Hero me = null!; public static GhostHero _ghost = null!; + private Hero? _ghostOwnerHero; + private dc.pr.Game? _ghostOwnerGame; + private NetNode? _ghostBootstrapNet; private Hero? _debugPerkAppliedHero; private string _debugPerkAppliedId = string.Empty; @@ -859,6 +862,19 @@ private void Hook_Game_onDispose(Hook_Game.orig_onDispose orig, dc.pr.Game self) private void Hook__Save_save(Hook__Save.orig_save orig, User u, bool onlyGameData) { + // Never let multiplayer GhostKing / KingSkin avatars enter MSave. A persisted GhostKing + // reloads through KingSkin.initGfx with a null cooldown map and fatals the game. + try + { + var purged = GhostHero.PurgeGhostKingsFromCurrentGame(); + if (purged > 0) + Logger.Information("[NetMod] Purged {Count} GhostKing(s) before save", purged); + } + catch (Exception ex) + { + Logger.Warning("[NetMod] GhostKing pre-save purge failed: {Message}", ex.Message); + } + if (_netRole == NetRole.Host) { orig(u, onlyGameData); @@ -1079,6 +1095,9 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le var localId = net?.id ?? 0; _ghost = new GhostHero(localId, game!, me, Logger, this); _ghost.SetLabel(me, GameMenu.Username); + _ghostOwnerHero = me; + _ghostOwnerGame = game; + _ghostBootstrapNet = net; if (!keepRemoteRenderGuardHeld) { for (int i = 0; i < clients.Length; i++) @@ -1127,6 +1146,15 @@ public void Hook_gameinit(Hook_Game.orig_init orig, dc.pr.Game self) public void OnHeroInit() { + var localGame = game ?? dc.pr.Game.Class.ME; + var hero = localGame?.hero ?? ModCore.Modules.Game.Instance?.HeroInstance; + if (localGame != null && hero != null && IsHeroRuntimeReadyForCoop(localGame, hero)) + { + me = hero; + me._targetable = true; + ApplyDebugHeroRuntimeOptions(); + } + GameMenu.MarkInRun(); ApplyDebugHeroRuntimeOptions(); } @@ -1160,7 +1188,10 @@ public void OnFrameUpdate(double dt) } void IOnHeroUpdate.OnHeroUpdate(double dt) { - if (me == null) return; + if (!TryGetReadyLocalHero(out _, out var localHero)) + return; + + me = localHero; var hitchStart = RuntimeHitchWatch.Start(); var stepStart = RuntimeHitchWatch.Start(); ApplyDebugHeroRuntimeOptions(); @@ -1173,6 +1204,10 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) if (_netRole == NetRole.None || _net == null) return; + stepStart = RuntimeHitchWatch.Start(); + EnsureCoopRuntimeBootstrap(); + LogHeroUpdateStepIfSlow("ModEntry.OnHeroUpdate.EnsureCoopRuntimeBootstrap", stepStart, null); + stepStart = RuntimeHitchWatch.Start(); TrySendCurrentDiveSkillInfoSnapshot(); LogHeroUpdateStepIfSlow("ModEntry.OnHeroUpdate.TrySendCurrentDiveSkillInfoSnapshot", stepStart, null); @@ -1275,5 +1310,177 @@ private static bool IsModFakeDeathCine(dc.GameCinematic? cine) return type == typeof(DeadBase) || type == typeof(RemoteDownedCorpse); } + internal void PrepareForContinueLaunch() + { + DisposeCoopGhostRuntime(); + // Continue keeps the live net session; discard old-world remote combat work + // before the next load so stale dive/attack packets cannot replay into it. + DrainRemoteCombatQueuesAfterLevelChange(); + ResetDoorMarkerState(); + FinishRemoteKingLevelTransition(); + s_remoteKingCreationBlockedUntilTicks = 0; + s_subLevelRenderGuardArmed = false; + try { _net?.ClearRemoteRoomMarkers(); } catch { } + me = null!; + game = null; + kingInitialized = false; + ResetHeroCosmeticSendCache(); + GameDataSync.ClearPendingBossRuneReloadState(); + } + + /// + /// After Continue/LoadSave (and any path that clears ), recreate the + /// GhostHero factory and wait for remote coords to spawn a fresh GhostKing on the live level. + /// + private void EnsureCoopRuntimeBootstrap() + { + if (_netRole == NetRole.None) + return; + + var net = _net; + if (net == null || !TryGetReadyLocalHero(out var localGame, out var localHero)) + return; + + me = localHero; + me._targetable = true; + game = localGame; + + if (_ghost != null && + (!ReferenceEquals(_ghostOwnerHero, me) || + !ReferenceEquals(_ghostOwnerGame, localGame))) + { + DisposeCoopGhostRuntime(); + } + + if (_ghost == null) + { + _ghost = new GhostHero(net.id, localGame, me, Logger, this); + _ghost.SetLabel(me, GameMenu.Username); + _ghostOwnerHero = me; + _ghostOwnerGame = localGame; + _ghostBootstrapNet = null; + ResetDoorMarkerState(); + FinishRemoteKingLevelTransition(); + s_remoteKingCreationBlockedUntilTicks = 0; + try { net.ClearRemoteRoomMarkers(); } catch { } + Logger.Information( + "[NetMod] Coop ghost runtime bootstrapped for continue/level hero={HeroUid} level={LevelId}", + me.__uid, + me._level?.map?.id?.ToString() ?? "?"); + } + + if (ReferenceEquals(_ghostBootstrapNet, net)) + return; + + _ghostBootstrapNet = net; + EnsureHeroVisibilityAfterRoomChange(me); + var currentLevelId = GetCurrentLevelId(); + if (!string.IsNullOrWhiteSpace(currentLevelId)) + SendLevel(currentLevelId); + SendCurrentRoomTarget(force: true); + ResetLocalHeroPositionSendCache(); + SendHeroCoords(); + if (me.inventory != null) + SendEquippedWeapons(me.inventory); + if (net.IsHost) + GameDataSync.SendBossRune(localGame.user, net); + GameDataSync.SendCurrentHeroCosmetics(localGame.user, net); + MarkDiveNetGuardAfterSpawnOrRoomChange(); + + // Immediately rebuild remote kings from any already-buffered peer poses so Continue + // does not wait for the next movement packet after a still-standing player loads in. + try { ReceiveGhostCoords(); } catch { } + } + + private bool TryGetReadyLocalHero(out dc.pr.Game localGame, out Hero localHero) + { + localGame = null!; + localHero = null!; + + var candidateGame = game ?? dc.pr.Game.Class.ME; + var candidateHero = candidateGame?.hero ?? ModCore.Modules.Game.Instance?.HeroInstance ?? me; + if (candidateGame == null || candidateHero == null) + return false; + + if (!IsHeroRuntimeReadyForCoop(candidateGame, candidateHero)) + return false; + + localGame = candidateGame; + localHero = candidateHero; + return true; + } + + private static bool IsHeroRuntimeReadyForCoop(dc.pr.Game localGame, Hero hero) + { + try + { + if (localGame == null || hero == null || hero.destroyed) + return false; + + if (localGame.user == null || localGame.data == null || localGame.controller == null) + return false; + + var gameHero = localGame.hero; + if (gameHero != null && !ReferenceEquals(gameHero, hero)) + return false; + + var level = hero._level; + if (level == null || level.map == null) + return false; + + if (level.game != null && !ReferenceEquals(level.game, localGame)) + return false; + + var currentLevel = localGame.curLevel; + if (currentLevel != null && currentLevel.map != null) + { + var heroLevelId = level.map.id?.ToString(); + var currentLevelId = currentLevel.map.id?.ToString(); + if (!string.IsNullOrWhiteSpace(heroLevelId) && + !string.IsNullOrWhiteSpace(currentLevelId) && + !string.Equals(heroLevelId, currentLevelId, StringComparison.Ordinal)) + { + return false; + } + } + + if (!HasHeroEntityRuntimeInitialized(hero)) + return false; + + var cooldown = hero.cd; + if (cooldown == null || cooldown.fastCheck == null) + return false; + + return localGame.curCine == null; + } + catch + { + return false; + } + } + + internal static bool HasHeroEntityRuntimeInitialized(Hero? hero) + { + try + { + return hero != null && hero.awake && hero.initDone; + } + catch + { + return false; + } + } + + internal static void ResetHeroCosmeticSendCache() + { + try + { + Instance?.ResetLocalSkinSendCache(); + } + catch + { + } + } + } } diff --git a/UI/ConnectionUI/ConnectionUI.cs b/UI/ConnectionUI/ConnectionUI.cs index 52cf02c..dfd0694 100644 --- a/UI/ConnectionUI/ConnectionUI.cs +++ b/UI/ConnectionUI/ConnectionUI.cs @@ -50,8 +50,55 @@ public ConnectionUI(Process parent) : base(parent) public static bool set_visible { - get => Instance?.root.visible ?? false; - set { if (Instance != null) Instance.root.visible = value; } + get + { + var instance = TryGetLiveInstance(); + return instance?.root?.visible ?? false; + } + set + { + var instance = TryGetLiveInstance(); + if (instance?.root == null) + return; + + try + { + instance.root.visible = value; + } + catch + { + // TitleScreen/Process teardown can invalidate root mid-return-to-menu. + Instance = null; + } + } + } + + /// + /// 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() + { + var instance = Instance; + if (instance == null) + return null; + + try + { + if (instance.root == null || instance.destroyed) + { + Instance = null; + return null; + } + } + catch + { + Instance = null; + return null; + } + + return instance; } /// After gamepad connect/disconnect, window metrics can change; re-run layout to avoid blurred/scaled UI. @@ -59,8 +106,9 @@ public static void RefreshLayoutAfterDisconnect() { try { - if (Instance != null && set_visible) - Instance.onResize(); + var instance = TryGetLiveInstance(); + if (instance != null && set_visible) + instance.onResize(); } catch { @@ -340,7 +388,7 @@ public void updateConnections() public static void NotifyConnectionsChanged() { - Instance?.updateConnections(); + TryGetLiveInstance()?.updateConnections(); } private void RefreshConnections(List? names) @@ -559,12 +607,20 @@ public static void Initialize(ModEntry entry) /// public static void EnsureCreated(TitleScreen screen) { - if (Instance != null && ReferenceEquals(Instance.parent, screen)) + var live = TryGetLiveInstance(); + if (live != null && ReferenceEquals(live.parent, screen)) return; + Instance = null; var connectionUI = new ConnectionUI(screen); screen.addChild(connectionUI); - connectionUI.root.set_visible(false); + try + { + connectionUI.root?.set_visible(false); + } + catch + { + } } diff --git a/UI/GameMenu.Connection.cs b/UI/GameMenu.Connection.cs index 1de128a..38e7377 100644 --- a/UI/GameMenu.Connection.cs +++ b/UI/GameMenu.Connection.cs @@ -1,14 +1,12 @@ using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; -using System.Threading; using dc.pr; using dc.ui; using HaxeProxy.Runtime; using Newtonsoft.Json; using ModCore.Utilities; using Microsoft.Win32; -using DeadCellsMultiplayerMod.UI; using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using ModCore.Modules; @@ -17,8 +15,6 @@ namespace DeadCellsMultiplayerMod { internal static partial class GameMenu { - private static bool _protocolMismatchPending; - private static void ForceExitToMainMenu() { try @@ -61,10 +57,144 @@ private static void ForceExitToMainMenu() } } + private static void ShowHostStatusMenu(TitleScreen screen) + { + if (_menuRebuildDepth > 0) + return; + _menuRebuildDepth++; + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + var multiplayerSaveLabel = GetMultiplayerSaveButtonLabel(); + var continueLabel = GetContinueButtonLabel(screen); + var startLabel = GetStartNormalModeButtonLabel(); + var canLaunch = AllPlayersReady(); + 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")); + AddMenuButton(screen, GetText.Instance.GetString("Back"), () => + { + StopNetworkFromMenu(); + SetRole(NetRole.None); + _menuSelection = NetRole.None; + ShowMultiplayerMenu(screen); + screen.ShouldAutoHideConnectionUI(false); + }, 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, GetText.Instance.GetString("Back")); + _inHostStatusMenu = true; + _inClientWaitingMenu = false; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open host status menu: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + _menuRebuildDepth--; + } + } + + private static void ShowClientWaitingMenu(TitleScreen screen) + { + if (_menuRebuildDepth > 0) + return; + _menuRebuildDepth++; + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + AddInfoLine(screen, $"Selected mode: {GetPendingLaunchSummaryLabel(screen)}", infoColor: 0xE0E0E0); + AddMenuButton(screen, GetReadyButtonLabel(), () => ToggleLocalReadyFromMenu(screen), Localize("Toggle your ready state")); + var multiplayerSaveLabel = GetMultiplayerSaveButtonLabel(); + AddMenuButton(screen, multiplayerSaveLabel, () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); + AddMenuButton( + screen, + GetText.Instance.GetString("Disconnect"), + () => {DisconnectFromMenu(screen); screen.ShouldAutoHideConnectionUI(false);}, + GetText.Instance.GetString("Disconnect and return to main menu")); + + RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); + RemoveDuplicatesKeepFirst(screen, GetReadyButtonLabel(), multiplayerSaveLabel, GetText.Instance.GetString("Disconnect")); + _inClientWaitingMenu = true; + _inHostStatusMenu = false; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open client waiting menu: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + _menuRebuildDepth--; + } + } + + private static void ShowLobbyNotFoundPopup(TitleScreen screen) + { + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + AddInfoLine(screen, GetText.Instance.GetString("Can't find lobby"), infoColor: 0xFF9090); + AddMenuButton( + screen, + 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; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open lobby not found popup: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } + } + + private static void DisconnectFromMenu(TitleScreen screen) + { + StopNetworkFromMenu(); + _waitingForHost = false; + ResetClientConnectState(); + _menuSelection = NetRole.None; + ResetSteamState(); + _inHostStatusMenu = false; + _inClientWaitingMenu = false; + screen.mainMenu(); + } + private static void StopNetworkFromMenu() { - _steamJoinLobbyResolvePending = false; - Interlocked.Increment(ref _steamJoinResolveGeneration); ResetHostDisconnectCountdown(); try { @@ -73,31 +203,48 @@ private static void StopNetworkFromMenu() catch { } lock (Sync) { - _inActualRun = false; - _remoteSeed = null; - _remoteSeedSequence = 0; - _consumedRemoteSeedSequence = 0; - _remoteLaunchKind = string.Empty; - _seedArrived = false; - ClearStructuredLaunchFlagsLocked(); - ClearPrecommittedHostRunSeedLocked(); - Monitor.PulseAll(Sync); + ResetLobbyLaunchStateLocked(); + ResetRemoteCoopStateLocked(); } + ResetLobbyReadyState(); ResetSteamState(); } + private static void EditUsername(TitleScreen screen) + { + OpenTextInput(screen, GetText.Instance.GetString("Username"), _username, value => + { + var cleaned = CleanUsername(value); + _username = cleaned; + SaveConfig(); + SendUsernameToRemote(); + ShowConnectionMenu(screen, _menuSelection == NetRole.None ? NetRole.Host : _menuSelection); + }, noSpaces: true); + } + public static void NotifyRemoteConnected(NetRole role) { ResetHostDisconnectCountdown(); - RunLaunchCoordinator.OnRemoteConnected(role); SendUsernameToRemote(); + SendLocalReadyState(); + SendCoopStateToRemote(); if (role == NetRole.Host) { _waitingForHost = false; SendCachedDataToRemote(); - SendCachedGeneratePayload(); + lock (Sync) + { + if (_inActualRun) + SendCachedGeneratePayload(); + } ConnectionUI.NotifyConnectionsChanged(); + + if (_menuSelection == NetRole.Host) + { + var ts = GetTitleScreen(); + if (ts != null) ShowHostStatusMenu(ts); + } } else if (role == NetRole.Client) { @@ -105,20 +252,30 @@ public static void NotifyRemoteConnected(NetRole role) _clientConnecting = false; _clientConnectAttempt = 0; ConnectionUI.NotifyConnectionsChanged(); + if (_menuSelection == NetRole.Client) + { + var ts = GetTitleScreen(); + if (ts != null) ShowClientWaitingMenu(ts); + } } + + RequestLobbyMenuRefresh(); } internal static void NotifyClientConnectAttempt(int attempt) { lock (Sync) { - _protocolMismatchPending = false; _clientConnectAttempt = attempt; _clientConnecting = true; _waitingForHost = true; } - ConnectionUI.NotifyConnectionsChanged(); + if (_menuSelection == NetRole.Client) + { + var ts = GetTitleScreen(); + if (ts != null) ShowClientWaitingMenu(ts); + } } internal static void NotifyClientConnectFailed() @@ -128,111 +285,47 @@ internal static void NotifyClientConnectFailed() _waitingForHost = false; _menuSelection = NetRole.Client; - EnqueueMainThread(() => - { - var screen = GetTitleScreen(); - if (screen != null) - { - screen.clearMenu(); - AddInfoLine(screen, Localize("Can't find lobby"), 0xFF9090); - AddInfoLine(screen, Localize("Check the address or Steam lobby code."), 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("OK"), () => - { - screen.clearMenu(); - ShowJoinTransportMenu(screen); - }, Localize("Return to join menu")); - screen.ShouldAutoHideConnectionUI(false); - } - }); - } - - internal static void NotifyProtocolMismatch( - string remoteBuild, - int remoteProtocol, - string localBuild, - int localProtocol, - NetRole localRole) - { - var remoteLabel = string.IsNullOrWhiteSpace(remoteBuild) ? "unknown" : remoteBuild.Trim(); - var detail = string.Create( - CultureInfo.InvariantCulture, - $"Other player: {remoteLabel} (protocol {remoteProtocol}). You: {localBuild} (protocol {localProtocol})."); - - MultiplayerUI.PushSystemMessage( - "Co-op version mismatch. Both players need the exact same mod build.", - 8.0, - 1.5); - ConnectionUI.NotifyConnectionsChanged(); - - // Hosts stay in their lobby and simply reject the incompatible peer. A joining client - // gets a clear menu error instead of an unexplained generic disconnect. - if (localRole != NetRole.Client) - return; - - lock (Sync) - { - _protocolMismatchPending = true; - _clientConnecting = false; - _waitingForHost = false; - } - - 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, - Localize("Install the exact same DeadCellsMultiplayerMod build on both computers."), - 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("OK"), () => - { - screen.clearMenu(); - ShowJoinTransportMenu(screen); - }, Localize("Return to join menu")); - screen.ShouldAutoHideConnectionUI(false); - }); + var ts = GetTitleScreen(); + if (ts != null) ShowLobbyNotFoundPopup(ts); } public static void NotifyRemoteDisconnected(NetRole role) { - RunLaunchCoordinator.OnRemoteDisconnected(role); + try { ModEntry.Instance?.HandleNetworkDisconnectGhostCleanup(role); } catch { } + if (role == NetRole.Host) { var disconnectedName = string.IsNullOrWhiteSpace(_remoteUsername) ? Localize("Guest") : _remoteUsername.Trim(); MultiplayerUI.PushSystemMessage(FormatLocalized("{0} disconnected from the server.", disconnectedName)); _remoteUsername = "guest"; + ResetLobbyReadyState(); + lock (Sync) + { + ResetRemoteCoopStateLocked(); + } + _genArrived = false; _seedArrived = false; + if (_menuSelection == NetRole.Host) + { + var ts = GetTitleScreen(); + if (ts != null) ShowHostStatusMenu(ts); + } - ConnectionUI.NotifyConnectionsChanged(); EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); + RequestLobbyMenuRefresh(); return; } - bool protocolMismatch; + bool wasInRun; lock (Sync) { - protocolMismatch = _protocolMismatchPending; - _protocolMismatchPending = false; + wasInRun = _inActualRun; + ResetLobbyLaunchStateLocked(); } - var wasInRun = _inActualRun; - // Unexpected disconnect used to clear only the menu's public NetRef. ModEntry still - // retained a client role/node and remote serializer/user state until another menu - // action happened, so hooks could continue behaving as a client after the host was - // already gone. Run the same centralized cleanup used by an explicit disconnect. - try - { - ModEntry.Instance?.StopNetworkFromMenu(); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Client disconnect cleanup failed: {Message}", ex.Message); - } + var saved = true; + if (wasInRun) + saved = TrySaveClientWorldBeforeHostAutoExit("host_disconnect"); SetRole(NetRole.None); NetRef = null; @@ -240,13 +333,17 @@ public static void NotifyRemoteDisconnected(NetRole role) _menuSelection = NetRole.None; ResetSteamState(); ClearNetworkCaches(); + _inHostStatusMenu = false; + _inClientWaitingMenu = false; _remoteUsername = "guest"; - if (!protocolMismatch) - MultiplayerUI.PushSystemMessage(Localize("Host disconnected from server.")); - if (wasInRun && !protocolMismatch) - StartHostDisconnectCountdown(); + ResetLobbyReadyState(); + _genArrived = false; + MultiplayerUI.PushSystemMessage(Localize("Host disconnected from server.")); + if (wasInRun) + StartHostDisconnectCountdown(savePending: !saved); EnqueueMainThreadCoalesced("ui:refresh-layout-after-disconnect", () => ConnectionUI.RefreshLayoutAfterDisconnect()); + RequestLobbyMenuRefresh(); } private static void SendUsernameToRemote() @@ -274,6 +371,7 @@ private static void SendCachedDataToRemote() var ld = GetCachedLevelDescSync(); if (ld != null) net.SendLevelDesc(JsonConvert.SerializeObject(ld)); + SendCoopStateToRemote(); } catch (Exception ex) { @@ -281,18 +379,21 @@ private static void SendCachedDataToRemote() } } + private static bool AllPlayersReady() + { + RefreshPlayersDisplayFromNetwork(); + if (_playersDisplay.Count == 0) + return false; + return _playersDisplay.All(p => p.Ready); + } + private static void ClearNetworkCaches() { - CacheLevelDescSync(null); lock (Sync) { - _remoteSeed = null; - _remoteSeedSequence = 0; - _consumedRemoteSeedSequence = 0; - _remoteLaunchKind = string.Empty; - _seedArrived = false; - ClearPrecommittedHostRunSeedLocked(); - Monitor.PulseAll(Sync); + _cachedLevelDescSync = null; + ResetLobbyLaunchStateLocked(); + ResetRemoteCoopStateLocked(); } } @@ -313,19 +414,38 @@ private static void ResetSteamState() _menuTransport = ConnectionTransport.Lan; } - private static void StartHostDisconnectCountdown() + private static void StartHostDisconnectCountdown(bool savePending = false) { + var now = DateTime.UtcNow; _hostDisconnectCountdownActive = true; - _hostDisconnectCountdownUntil = DateTime.UtcNow.AddSeconds(HostDisconnectCountdownSeconds); + CaptureHostDisconnectCountdownGame(); + _hostDisconnectCountdownUntil = now.AddSeconds(HostDisconnectCountdownSeconds); _lastHostDisconnectCountdown = HostDisconnectCountdownSeconds; + _hostDisconnectSavePending = savePending; + _forceMultiplayerSaveStore = savePending; + if (savePending) + { + _hostDisconnectSaveRetryAt = now.AddMilliseconds(HostDisconnectSaveRetryMs); + _hostDisconnectSaveDeadline = now.AddSeconds(HostDisconnectSaveMaxSeconds); + } + else + { + _hostDisconnectSaveRetryAt = DateTime.MinValue; + _hostDisconnectSaveDeadline = DateTime.MinValue; + } MultiplayerUI.PushSystemMessage(FormatLocalized("Back to menu in {0}...", HostDisconnectCountdownSeconds)); } private static void ResetHostDisconnectCountdown() { _hostDisconnectCountdownActive = false; + _hostDisconnectCountdownGameRef = null; _hostDisconnectCountdownUntil = DateTime.MinValue; _lastHostDisconnectCountdown = -1; + _hostDisconnectSavePending = false; + _forceMultiplayerSaveStore = false; + _hostDisconnectSaveRetryAt = DateTime.MinValue; + _hostDisconnectSaveDeadline = DateTime.MinValue; } private static void UpdateHostDisconnectCountdown() @@ -333,7 +453,29 @@ private static void UpdateHostDisconnectCountdown() if (!_hostDisconnectCountdownActive) return; - var remaining = (int)Math.Ceiling((_hostDisconnectCountdownUntil - DateTime.UtcNow).TotalSeconds); + if (!IsHostDisconnectCountdownGameStillActive()) + { + _log?.Information("[NetMod] Host-disconnect auto-exit canceled because client already left the run"); + ResetHostDisconnectCountdown(); + return; + } + + var now = DateTime.UtcNow; + if (_hostDisconnectSavePending && now >= _hostDisconnectSaveRetryAt) + { + if (TrySaveClientWorldBeforeHostAutoExit("host_disconnect_retry")) + { + _hostDisconnectSavePending = false; + _forceMultiplayerSaveStore = false; + } + else + { + _forceMultiplayerSaveStore = true; + _hostDisconnectSaveRetryAt = now.AddMilliseconds(HostDisconnectSaveRetryMs); + } + } + + var remaining = (int)Math.Ceiling((_hostDisconnectCountdownUntil - now).TotalSeconds); if (remaining < 0) remaining = 0; @@ -346,10 +488,135 @@ private static void UpdateHostDisconnectCountdown() if (remaining > 0) return; + if (_hostDisconnectSavePending && now < _hostDisconnectSaveDeadline) + { + _hostDisconnectCountdownUntil = now.AddSeconds(1); + return; + } + _hostDisconnectCountdownActive = false; + _hostDisconnectCountdownGameRef = null; + _hostDisconnectSavePending = false; + _forceMultiplayerSaveStore = false; ForceExitToMainMenu(); } + private static void CaptureHostDisconnectCountdownGame() + { + try + { + var game = dc.pr.Game.Class.ME ?? ModEntry.Instance?.game; + _hostDisconnectCountdownGameRef = game == null ? null : new WeakReference(game); + } + catch + { + _hostDisconnectCountdownGameRef = null; + } + } + + private static bool IsHostDisconnectCountdownGameStillActive() + { + var gameRef = _hostDisconnectCountdownGameRef; + if (gameRef == null) + return true; + + if (!gameRef.TryGetTarget(out var scheduledGame) || scheduledGame == null) + return false; + + try + { + if (scheduledGame.destroyed) + return false; + } + catch + { + return false; + } + + try + { + var currentGame = dc.pr.Game.Class.ME ?? ModEntry.Instance?.game; + return ReferenceEquals(currentGame, scheduledGame); + } + catch + { + return false; + } + } + + private static bool TrySaveClientWorldBeforeHostAutoExit(string reason) + { + try + { + var main = dc.Main.Class.ME; + var user = main?.user; + if (main == null || user == null) + return true; + + GameDataSync.SwapToOriginalUserData(user); + var serializerSwapped = GameDataSync.SwapToLocalSerializerSync(); + _forceMultiplayerSaveStore = true; + try + { + var saved = main.writeSave(); + if (saved) + { + if (!TryValidateClientMultiplayerSave(out var validationError)) + { + _log?.Warning("[NetMod] Client multiplayer save validation failed before host-disconnect auto-exit ({Reason}): {Error}", reason, validationError); + return false; + } + + _log?.Information("[NetMod] Saved client multiplayer world before host-disconnect auto-exit ({Reason})", reason); + return true; + } + + _log?.Warning("[NetMod] Client world save is pending before host-disconnect auto-exit ({Reason})", reason); + return false; + } + finally + { + _forceMultiplayerSaveStore = false; + if (!serializerSwapped) + GameDataSync.SwapToLocalSerializerSync(); + } + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to save client world before host-disconnect auto-exit ({Reason}): {Message}", reason, ex.Message); + return false; + } + } + + private static bool TryValidateClientMultiplayerSave(out string error) + { + error = string.Empty; + try + { + var savePath = GetMultiplayerSaveRelativeFilePath(null); + var bytes = dc.tool.File.Class.getBytes.Invoke(MakeHLString(savePath)); + if (bytes == null) + { + error = "saved bytes were null"; + return false; + } + + var loaded = dc.tool.Save.Class.readSave.Invoke(bytes); + if (loaded == null) + { + error = "readSave returned null"; + return false; + } + + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + internal static string Localize(string message) { return GetText.Instance.GetString(message); @@ -375,7 +642,13 @@ public static void ReceiveGeneratePayload(string json) var payload = JsonConvert.DeserializeAnonymousType(json, new { levelDesc = new LevelDescSync(), - rawDesc = string.Empty + rawDesc = string.Empty, + launchAction = string.Empty, + launchCustom = false, + launchStreamEnabled = false, + newCoopWorldPrepared = false, + coopId = string.Empty, + hostHasContinueSave = false }); if (payload == null) return; @@ -390,11 +663,26 @@ public static void ReceiveGeneratePayload(string json) _log?.Information("[NetMod] Client received raw LevelDesc: {Json}", payload.rawDesc); } + ApplyReceivedPendingLaunch(payload.launchAction, payload.launchCustom, payload.launchStreamEnabled); + if (!string.IsNullOrWhiteSpace(payload.coopId)) + ReceiveRemoteCoopState(1, payload.coopId, payload.hostHasContinueSave); + lock (Sync) + { + _receivedLaunchPayload = true; + _receivedNewCoopWorldPrepared = payload.newCoopWorldPrepared; + } + TryStoreRemoteCoopIdForPendingNewGame(); + lock (Sync) { if (_role == NetRole.Client && !_inActualRun) + { + _genArrived = true; _pendingAutoStart = true; + } } + + RequestLobbyMenuRefresh(); } catch (Exception ex) { @@ -436,6 +724,14 @@ private sealed class MenuConfig public string player_id { get; set; } = Guid.NewGuid().ToString("N"); } + private sealed class PlayerInfo + { + public int UserId { get; set; } + public string Name { get; set; } = "guest"; + public bool Ready { get; set; } + public bool IsHost { get; set; } + } + private static void LoadConfig() { try @@ -654,35 +950,14 @@ private static bool TryParseVdfPair(string line, out string key, out string valu return true; } - private static string BuildStatus(NetRole role) - { - var net = NetRef; - if (role == NetRole.Client && _clientConnecting) - { - if (_clientConnectAttempt > 0) - return $"{GetText.Instance.GetString("connecting...")} ({_clientConnectAttempt}/{ClientConnectMaxAttempts})"; - return GetText.Instance.GetString("connecting..."); - } - - if (net != null && net.HasRemote) - return role == NetRole.Host - ? GetText.Instance.GetString("client connected") - : GetText.Instance.GetString("connected to host"); - - if (role == NetRole.Client) - return _waitingForHost - ? GetText.Instance.GetString("waiting for the host") - : GetText.Instance.GetString("not connected"); - - return GetText.Instance.GetString("waiting for client"); - } - private static void ResetClientConnectState() { lock (Sync) { _clientConnectAttempt = 0; _clientConnecting = false; + _pendingClientRestartSeed = null; + _pendingClientRestartReason = string.Empty; } } @@ -1021,32 +1296,257 @@ private static void OpenTextInput(TitleScreen screen, string title, string initi } } + private static void TryAddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null) + { + try + { + AddMenuButton(screen, label, onClick, help); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Menu add failed for {Label}: {Message}", label, ex.Message); + } + } + + private static void AddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null, bool? isEnabled = null) + { + var cb = new HlAction(onClick); + var labelStr = MakeHLString(label); + var helpStr = MakeHLString(help ?? string.Empty); + int colorVal = 0xFFFFFF; + var color = Ref.From(ref colorVal); + screen.addMenu(labelStr, cb, helpStr, isEnabled, color); + } + + private static void AddInfoLine(TitleScreen screen, string text, int? infoColor = null) + { + int colorVal = infoColor ?? 0xFFFFFF; + var labelStr = MakeHLString(text); + var helpStr = MakeHLString(string.Empty); + var color = Ref.From(ref colorVal); + var cb = new HlAction(() => { }); + screen.addMenu(labelStr, cb, helpStr, false, color); + } + private static object? GetMemberValue(object? obj, string name, bool ignoreCase) - => TitleScreenReflection.GetMemberValue(obj, name, ignoreCase); + { + if (obj == null || string.IsNullOrWhiteSpace(name)) return null; + + const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + var type = obj.GetType(); + var flags = ignoreCase ? Flags | BindingFlags.IgnoreCase : Flags; + try + { + var prop = type.GetProperty(name, flags); + if (prop != null) return prop.GetValue(obj); + + var field = type.GetField(name, flags); + if (field != null) return field.GetValue(obj); + } + catch { } + + return null; + } private static bool TrySetMember(object? obj, string name, object? value) - => TitleScreenReflection.TrySetMember(obj, name, value); + { + if (obj == null || string.IsNullOrWhiteSpace(name)) return false; + + const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase; + var type = obj.GetType(); + try + { + var prop = type.GetProperty(name, Flags); + if (prop != null && prop.CanWrite) + { + prop.SetValue(obj, value); + return true; + } + + var field = type.GetField(name, Flags); + if (field != null) + { + field.SetValue(obj, value); + return true; + } + } + catch { } + + return false; + } private static dc.String MakeHLString(string value) { return value.AsHaxeString(); } - private static void AddMenuButton( - TitleScreen screen, - string label, - Action onClick, - string? help = null, - int textColor = 0xFFFFFF) + private static bool GetIsMainMenu(TitleScreen screen) { - var cb = new HlAction(onClick); - var labelStr = MakeHLString(label); - var helpStr = MakeHLString(help ?? string.Empty); - int colorVal = textColor; - var color = Ref.From(ref colorVal); - screen.addMenu(labelStr, cb, helpStr, null, color); + try + { + var val = GetMemberValue(screen, "isMainMenu", true); + if (val is bool b) return b; + } + catch { } + return false; + } + + private static void SetIsMainMenu(TitleScreen screen, bool value) + { + try + { + TrySetMember(screen, "isMainMenu", value); + } + catch { } + } + + private static int GetArrayLength(object arrObj) + { + try + { + var lenObj = GetMemberValue(arrObj, "length", true); + if (lenObj is IConvertible conv) + return conv.ToInt32(null); + } + catch { } + return 0; } + private static int FindMenuIndexByLabel(object? arrObj, string label) + { + if (arrObj == null) return -1; + try + { + var type = arrObj.GetType(); + var getDyn = type.GetMethod("getDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getDyn == null) return -1; + + int len = GetArrayLength(arrObj); + for (int i = 0; i < len; i++) + { + var item = getDyn.Invoke(arrObj, new object[] { i }); + var text = GetMenuLabel(item); + if (text.Equals(label, StringComparison.OrdinalIgnoreCase)) + return i; + } + } + catch { } + return -1; + } + + private static string GetMenuLabel(object? menuItem) + { + if (menuItem == null) return string.Empty; + + try + { + var t = GetMemberValue(menuItem, "t", true); + if (t is dc.String ds) + return ds.ToString() ?? string.Empty; + + var textValue = GetMemberValue(t ?? menuItem, "text", true) + ?? GetMemberValue(t ?? menuItem, "str", true); + if (textValue != null) + return textValue.ToString() ?? string.Empty; + + return t?.ToString() ?? menuItem.ToString() ?? string.Empty; + } + catch + { + return string.Empty; + } + } + + private static void RemoveMenuItems(TitleScreen screen, params string[] labels) + { + if (labels.Length == 0) return; + var arrObj = GetMemberValue(screen, "menuItems", true); + if (arrObj == null) return; + + try + { + var type = arrObj.GetType(); + var getDyn = type.GetMethod("getDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var removeDyn = type.GetMethod("removeDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? type.GetMethod("remove", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getDyn == null || removeDyn == null) return; + + var targets = new System.Collections.Generic.List(); + int len = GetArrayLength(arrObj); + for (int i = 0; i < len; i++) + { + var item = getDyn.Invoke(arrObj, new object[] { i }); + if (item == null) + continue; + var label = GetMenuLabel(item); + foreach (var l in labels) + { + if (label.Equals(l, StringComparison.OrdinalIgnoreCase)) + { + targets.Add(item); + break; + } + } + } + + foreach (var it in targets) + { + removeDyn.Invoke(arrObj, new object[] { it }); + } + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to clean menu items: {Message}", ex.Message); + } + } + + private static void RemoveDuplicatesKeepFirst(TitleScreen screen, params string[] labels) + { + if (labels.Length == 0) return; + var arrObj = GetMemberValue(screen, "menuItems", true); + if (arrObj == null) return; + + try + { + var type = arrObj.GetType(); + var getDyn = type.GetMethod("getDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var removeDyn = type.GetMethod("removeDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? type.GetMethod("remove", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getDyn == null || removeDyn == null) return; + + var seen = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + var toRemove = new System.Collections.Generic.List(); + + int len = GetArrayLength(arrObj); + for (int i = 0; i < len; i++) + { + var item = getDyn.Invoke(arrObj, new object[] { i }); + if (item == null) + continue; + var label = GetMenuLabel(item); + foreach (var l in labels) + { + if (label.Equals(l, StringComparison.OrdinalIgnoreCase)) + { + if (!seen.Add(label)) + toRemove.Add(item); + break; + } + } + } + + foreach (var it in toRemove) + { + removeDyn.Invoke(arrObj, new object[] { it }); + } + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to clean duplicate menu items: {Message}", ex.Message); + } + } + + private static void StoreTitleScreen(TitleScreen ts) { _titleScreenRef = new WeakReference(ts); diff --git a/UI/GameMenu.CoopIdentity.cs b/UI/GameMenu.CoopIdentity.cs new file mode 100644 index 0000000..59ede82 --- /dev/null +++ b/UI/GameMenu.CoopIdentity.cs @@ -0,0 +1,417 @@ +using System.Diagnostics; +using System.Globalization; + +namespace DeadCellsMultiplayerMod +{ + internal static partial class GameMenu + { + 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; + + public static void ReceiveRemoteCoopState(int userId, string? coopId, bool hasContinueSave) + { + if (userId <= 0) + return; + + var normalized = MUser.NormalizeCoopId(coopId); + lock (Sync) + { + _remoteCoopStates[userId] = new RemoteCoopState(normalized, hasContinueSave); + } + + TryStoreRemoteCoopIdForPendingNewGame(); + RequestLobbyMenuRefresh(); + } + + private static void ResetRemoteCoopStateLocked() + { + _remoteCoopStates.Clear(); + _receivedLaunchPayload = false; + _receivedNewCoopWorldPrepared = false; + _pendingNewCoopWorldIdAssigned = false; + _storedPendingNewCoopWorldCoopId = null; + _storedPendingNewCoopWorldSeed = null; + _lastLoggedClientContinueBlockReason = string.Empty; + } + + private static void SendCoopStateToRemote() + { + var net = NetRef; + if (net == null || !net.IsAlive) + return; + + var localCoopId = MUser.GetCurrentCoopId() ?? string.Empty; + var hasContinueSave = HasLocalContinueSaveState(out _); + + try + { + net.SendCoopState(localCoopId, hasContinueSave); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to send coop id: {Message}", ex.Message); + } + } + + private static void NotifyMultiplayerSaveSlotChanged() + { + InvalidateLocalContinueSaveStateCache(); + SendCoopStateToRemote(); + RequestLobbyMenuRefresh(); + } + + private static void PrepareCoopIdentityForPendingLaunch(PendingLaunchAction action) + { + if (_role != NetRole.Host) + return; + + if (action != PendingLaunchAction.NewGame) + { + lock (Sync) + { + _pendingNewCoopWorldIdAssigned = false; + } + + SendCoopStateToRemote(); + return; + } + + var shouldCreate = false; + lock (Sync) + { + if (!_pendingNewCoopWorldIdAssigned || _pendingLaunchAction != PendingLaunchAction.NewGame) + { + _pendingNewCoopWorldIdAssigned = true; + shouldCreate = true; + } + } + + if (!shouldCreate) + { + SendCoopStateToRemote(); + return; + } + + int? seed; + lock (Sync) + { + seed = _serverSeed; + } + + var coopId = MUser.EnsureCoopIdForNewCoopWorld(_playerId, seed); + _log?.Information("[NetMod] Created coop id {CoopId} for new coop world", coopId); + SendCoopStateToRemote(); + } + + private static void TryStoreRemoteCoopIdForPendingNewGame() + { + string? remoteCoopId; + int? seed; + lock (Sync) + { + if (_role != NetRole.Client || + !_receivedLaunchPayload || + !_receivedNewCoopWorldPrepared || + _pendingLaunchAction != PendingLaunchAction.NewGame) + { + return; + } + + if (!_remoteCoopStates.TryGetValue(1, out var hostState) || + string.IsNullOrWhiteSpace(hostState.CoopId)) + { + return; + } + + remoteCoopId = hostState.CoopId; + seed = _remoteSeed; + if (string.Equals(_storedPendingNewCoopWorldCoopId, remoteCoopId, StringComparison.Ordinal) && + _storedPendingNewCoopWorldSeed == seed) + { + return; + } + } + + if (!MUser.SetCoopId(remoteCoopId, GetRemoteHostIdentity(), seed)) + { + _log?.Warning("[NetMod] Failed to store host coop id for new coop world"); + return; + } + + lock (Sync) + { + _storedPendingNewCoopWorldCoopId = remoteCoopId; + _storedPendingNewCoopWorldSeed = seed; + } + + _log?.Information("[NetMod] Stored host coop id {CoopId} for new coop world", remoteCoopId); + SendCoopStateToRemote(); + } + + private static bool CanHostStartContinue(out string reason) + { + if (!AllPlayersReady()) + { + reason = "Not all players ready"; + return false; + } + + return IsHostContinueCompatible(out reason); + } + + private static bool IsHostContinueCompatible(out string reason) + { + if (!TryGetLocalContinueReadiness(out var localCoopId, out reason)) + return false; + + var net = NetRef; + if (net == null || !net.IsAlive) + { + reason = ContinueReasonOk; + return true; + } + + if (!net.TryGetRemoteUserSnapshots(out var snapshots)) + { + reason = ContinueReasonOk; + return true; + } + + try + { + if (snapshots.Count == 0) + { + reason = ContinueReasonOk; + return true; + } + + lock (Sync) + { + for (var i = 0; i < snapshots.Count; i++) + { + var remoteId = snapshots[i].Id; + if (remoteId <= 0) + continue; + + if (!_remoteCoopStates.TryGetValue(remoteId, out var remoteState)) + { + reason = "Client coop id not received"; + return false; + } + + if (!remoteState.HasContinueSave) + { + reason = "Client has no continue save"; + return false; + } + + if (string.IsNullOrWhiteSpace(remoteState.CoopId)) + { + reason = "Client has no local coop id"; + return false; + } + + if (!string.Equals(localCoopId, remoteState.CoopId, StringComparison.Ordinal)) + { + reason = "Coop world mismatch"; + return false; + } + } + } + } + finally + { + NetNode.ReleaseConsumedList(snapshots); + } + + reason = ContinueReasonOk; + return true; + } + + private static bool CanClientAcceptContinueLaunchLocked(out string reason) + { + if (!TryGetLocalContinueReadiness(out var localCoopId, out reason)) + return false; + + if (!_remoteCoopStates.TryGetValue(1, out var hostState)) + { + reason = "Host coop id not received"; + return false; + } + + if (!hostState.HasContinueSave) + { + reason = "Host has no continue save"; + return false; + } + + if (string.IsNullOrWhiteSpace(hostState.CoopId)) + { + reason = "Host has no coop id"; + return false; + } + + if (!string.Equals(localCoopId, hostState.CoopId, StringComparison.Ordinal)) + { + reason = "Coop world mismatch"; + return false; + } + + reason = ContinueReasonOk; + return true; + } + + private static bool TryGetLocalContinueReadiness(out string localCoopId, out string reason) + { + localCoopId = string.Empty; + + if (!HasLocalContinueSaveState(out reason)) + return false; + + var coopId = MUser.GetCurrentCoopId(); + if (string.IsNullOrWhiteSpace(coopId)) + { + reason = "No local coop id"; + return false; + } + + localCoopId = coopId; + reason = ContinueReasonOk; + return true; + } + + private static bool HasLocalContinueSaveState(out string reason) + { + var slot = ResolveCurrentSaveSlotForCache(); + var now = Stopwatch.GetTimestamp(); + lock (Sync) + { + if (_continueSaveCacheValid && + _continueSaveCacheSlot == slot && + Stopwatch.GetElapsedTime(_continueSaveCacheTicks, now).TotalSeconds < ContinueSaveCacheSeconds) + { + reason = _continueSaveCacheReason; + return _continueSaveCacheHasSave; + } + } + + var hasSave = ReadLocalContinueSaveState(out reason); + lock (Sync) + { + _continueSaveCacheSlot = slot; + _continueSaveCacheTicks = now; + _continueSaveCacheValid = true; + _continueSaveCacheHasSave = hasSave; + _continueSaveCacheReason = reason; + } + + return hasSave; + } + + private static bool ReadLocalContinueSaveState(out string reason) + { + try + { + var relativePath = GetMultiplayerSaveRelativeFilePath(null); + if (!dc.tool.File.Class.exists.Invoke(MakeHLString(relativePath))) + { + reason = "No continue save"; + return false; + } + + var bytes = dc.tool.File.Class.getBytes.Invoke(MakeHLString(relativePath)); + if (bytes == null) + { + reason = "No continue save"; + return false; + } + + var user = dc.tool.Save.Class.readSave.Invoke(bytes); + if (user?.mainGameData == null) + { + reason = "No continue save"; + return false; + } + + reason = ContinueReasonOk; + return true; + } + catch (Exception ex) + { + reason = "No continue save"; + _log?.Warning("[NetMod] Failed to validate multiplayer continue save: {Message}", ex.Message); + return false; + } + } + + private static void InvalidateLocalContinueSaveStateCache() + { + lock (Sync) + { + _continueSaveCacheValid = false; + _continueSaveCacheSlot = -1; + _continueSaveCacheTicks = 0; + _continueSaveCacheHasSave = false; + _continueSaveCacheReason = ContinueReasonOk; + } + } + + private static int ResolveCurrentSaveSlotForCache() + { + try + { + var current = dc.Main.Class.ME?.options?.curSlot; + if (current.HasValue && current.Value >= 0) + return current.Value; + } + catch + { + } + + return 0; + } + + private static void LogClientContinueBlockReasonLocked(string reason) + { + if (string.Equals(_lastLoggedClientContinueBlockReason, reason, StringComparison.Ordinal)) + return; + + _lastLoggedClientContinueBlockReason = reason; + _log?.Warning("[NetMod] Continue Coop blocked on client: {Reason}", reason); + } + + private static string GetRemoteHostIdentity() + { + if (_steamHostSteamId != 0UL) + return _steamHostSteamId.ToString(CultureInfo.InvariantCulture); + + return string.IsNullOrWhiteSpace(_remoteUsername) + ? "host" + : _remoteUsername.Trim(); + } + + private readonly struct RemoteCoopState + { + public readonly string? CoopId; + public readonly bool HasContinueSave; + + public RemoteCoopState(string? coopId, bool hasContinueSave) + { + CoopId = coopId; + HasContinueSave = hasContinueSave; + } + } + } +} diff --git a/UI/GameMenu.MultiplayerLaunch.cs b/UI/GameMenu.MultiplayerLaunch.cs new file mode 100644 index 0000000..bb6f1c2 --- /dev/null +++ b/UI/GameMenu.MultiplayerLaunch.cs @@ -0,0 +1,912 @@ +using System; +using dc; +using dc.pr; +using dc.tool; +using dc.ui; + +namespace DeadCellsMultiplayerMod +{ + internal static partial class GameMenu + { + private 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; + // 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; + + private static void InitializeMultiplayerLaunchHooks() + { + if (_launchHooksAttached) + return; + + Hook_TitleScreen.startNewGame += Hook_TitleScreen_startNewGame; + Hook_TitleScreen.confirmNewGame += Hook_TitleScreen_confirmNewGame; + _launchHooksAttached = true; + } + + private static void Hook_TitleScreen_startNewGame(Hook_TitleScreen.orig_startNewGame orig, TitleScreen self, bool custom) + { + var streamEnabled = TryGetStreamEnabled(self); + NormalizePendingNewGameLaunch(ref custom, ref streamEnabled); + RememberPendingLaunch(PendingLaunchAction.NewGame, custom, streamEnabled, sendToRemote: _role == NetRole.Host); + // CustomGame.close() saves customGameData_{slot}.json immediately before this hook. + // Push that authoritative file to the client before the RunLaunch barrier / native load. + if (custom && _role == NetRole.Host) + SendCustomGameDataToRemote(); + orig(self, custom); + } + + private static void Hook_TitleScreen_confirmNewGame(Hook_TitleScreen.orig_confirmNewGame orig, TitleScreen self, bool custom) + { + var streamEnabled = TryGetStreamEnabled(self); + NormalizePendingNewGameLaunch(ref custom, ref streamEnabled); + RememberPendingLaunch(PendingLaunchAction.NewGame, custom, streamEnabled, sendToRemote: _role == NetRole.Host); + if (custom && _role == NetRole.Host) + SendCustomGameDataToRemote(); + orig(self, custom); + } + + private static void RememberPendingLaunch(PendingLaunchAction action, bool custom, bool streamEnabled, bool sendToRemote, bool assignNewCoopWorld = true) + { + if (sendToRemote && (action != PendingLaunchAction.NewGame || assignNewCoopWorld)) + PrepareCoopIdentityForPendingLaunch(action); + else if (sendToRemote) + SendCoopStateToRemote(); + + lock (Sync) + { + _pendingLaunchAction = action; + _pendingLaunchCustom = custom; + _pendingLaunchStreamEnabled = streamEnabled; + if (action == PendingLaunchAction.NewGame && sendToRemote && !assignNewCoopWorld) + _pendingNewCoopWorldIdAssigned = false; + InvalidateGeneratePayloadCacheLocked(); + } + + if (sendToRemote) + { + SendLaunchModeToRemote(); + SendCachedGeneratePayload(); + } + } + + private static void NormalizePendingNewGameLaunch(ref bool custom, ref bool streamEnabled) + { + lock (Sync) + { + if (_hasAuthoritativePendingNewGameLaunch) + { + custom = _authoritativePendingNewGameCustom; + streamEnabled = _authoritativePendingNewGameStreamEnabled; + } + } + } + + private static void SetAuthoritativePendingNewGameLaunch(bool custom, bool streamEnabled) + { + lock (Sync) + { + _hasAuthoritativePendingNewGameLaunch = true; + _authoritativePendingNewGameCustom = custom; + _authoritativePendingNewGameStreamEnabled = streamEnabled; + } + } + + internal static void ClearAuthoritativePendingNewGameLaunch() + { + lock (Sync) + { + _hasAuthoritativePendingNewGameLaunch = false; + _authoritativePendingNewGameCustom = false; + _authoritativePendingNewGameStreamEnabled = false; + } + } + + internal static bool TryGetAuthoritativePendingNewGameLaunch(out bool custom, out bool streamEnabled) + { + lock (Sync) + { + if (_hasAuthoritativePendingNewGameLaunch) + { + custom = _authoritativePendingNewGameCustom; + streamEnabled = _authoritativePendingNewGameStreamEnabled; + return true; + } + } + + custom = false; + streamEnabled = false; + return false; + } + + private static void InvalidateGeneratePayloadCacheLocked() + { + _cachedGeneratePayloadSignature = string.Empty; + _cachedGeneratePayloadJson = null; + } + + private static bool TryGetStreamEnabled(TitleScreen? screen) + { + try + { + return screen != null && screen.isStreamEnable; + } + catch + { + return false; + } + } + + private static string GetModeLabel(bool isCustom) + { + return isCustom ? "Custom Mode" : "Normal Mode"; + } + + private static bool ResolveCurrentSaveIsCustom(TitleScreen? screen) + { + try + { + var mainGameData = screen?.user?.mainGameData; + if (mainGameData != null) + return mainGameData.isCustom; + } + catch + { + } + + lock (Sync) + { + return _pendingLaunchCustom; + } + } + + private static string GetContinueButtonLabel(TitleScreen? screen) + { + return string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"Continue ({GetModeLabel(ResolveCurrentSaveIsCustom(screen))})"); + } + + private static string GetStartNormalModeButtonLabel() + { + return string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{GetModeLabel(isCustom: false)}"); + } + + private static void ContinueHostRun(TitleScreen screen) + { + if (!CanHostStartContinue(out var reason)) + { + _log?.Warning("[NetMod] Continue Coop blocked on host: {Reason}", reason); + return; + } + + StartHostServerOnly(); + ClearAuthoritativePendingNewGameLaunch(); + TrySendContinueLaunchPrerequisites(screen); + RememberPendingLaunch( + PendingLaunchAction.LoadSave, + ResolveCurrentSaveIsCustom(screen), + TryGetStreamEnabled(screen), + sendToRemote: true); + TryLaunchContinue(screen); + } + + private static void OpenHostCustomMode(TitleScreen screen) + { + if (!AllPlayersReady()) + return; + + if (!EnsureCustomModeScreenUser(screen)) + { + _log?.Warning("[NetMod] Failed to prepare custom mode user for selected multiplayer save slot"); + return; + } + + SetAuthoritativePendingNewGameLaunch( + custom: true, + streamEnabled: TryGetStreamEnabled(screen)); + RememberPendingLaunch( + PendingLaunchAction.NewGame, + custom: true, + TryGetStreamEnabled(screen), + sendToRemote: true, + assignNewCoopWorld: false); + + try + { + // Vanilla CustomGame's "for mod" constructor path clears CustomGame.user and + // exits through _Boot.exit() on close. The preset widgets then dereference + // user.itemMeta, which crashes on default preset clicks. Use the normal + // title-screen custom mode flow and keep multiplayer launch state in our + // pending-launch hooks instead of opting into the native mod-only branch. + screen.customModeMenu(isForMod: false); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open custom mode menu: {Message}", ex.Message); + } + } + + private static bool EnsureCustomModeScreenUser(TitleScreen? screen) + { + if (screen == null) + return false; + + if (TryPrepareCustomModeUser(screen.user, out var currentUser)) + { + screen.user = currentUser; + return true; + } + + try + { + var loadedUser = Save.Class.tryLoad.Invoke(); + if (TryPrepareCustomModeUser(loadedUser, out var preparedLoadedUser)) + { + screen.user = preparedLoadedUser; + return true; + } + } + catch (Exception ex) + { + _log?.Debug("[NetMod] Save.tryLoad() failed while opening custom mode: {Message}", ex.Message); + } + + try + { + var freshUser = new User(); + if (TryPrepareCustomModeUser(freshUser, out var preparedFreshUser)) + { + screen.user = preparedFreshUser; + return true; + } + } + catch (Exception ex) + { + _log?.Debug("[NetMod] Fresh User() failed while opening custom mode: {Message}", ex.Message); + } + + return false; + } + + /// + /// Main.getGame(NewGame) loads User via Save.tryLoad independently of TitleScreen.user. + /// Prepare the live User that User.newGame will use so CustomGameData.checkIntegrity + /// does not null-deref itemMeta on the client. + /// + internal static bool PrepareUserForCustomModeLaunch(User? user) + { + return TryPrepareCustomModeUser(user, out _); + } + + private static bool TryPrepareCustomModeUser(User? candidate, out User preparedUser) + { + preparedUser = null!; + if (candidate == null) + return false; + + try + { + candidate.onReload(); + } + catch + { + } + + try + { + var itemMeta = candidate.itemMeta ?? new ItemMetaManager(candidate); + itemMeta._user = candidate; + try + { + itemMeta.onReload(); + } + catch + { + } + + candidate.itemMeta = itemMeta; + } + catch + { + } + + try + { + var mainGameData = candidate.mainGameData; + if (mainGameData != null && mainGameData.sUser == null) + mainGameData.sUser = candidate; + } + catch + { + } + + if (candidate.itemMeta == null) + return false; + + preparedUser = candidate; + return true; + } + + private static string GetCustomGameDataRelativePath(int? slot = null) + { + return string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"customGameData_{ResolveSaveSlotNumber(slot)}.json"); + } + + private static bool TryReadLocalCustomGameDataJson(out string json) + { + json = string.Empty; + try + { + var relativePath = GetCustomGameDataRelativePath(); + if (!dc.tool.File.Class.exists.Invoke(MakeHLString(relativePath))) + { + _log?.Warning( + "[NetMod] Host customGameData file missing at {Path}", + GetAbsoluteSavePath(relativePath)); + return false; + } + + var bytes = dc.tool.File.Class.getBytes.Invoke(MakeHLString(relativePath)); + var text = bytes?.toString()?.ToString(); + if (string.IsNullOrWhiteSpace(text)) + { + _log?.Warning("[NetMod] Host customGameData file is empty: {Path}", relativePath); + return false; + } + + json = text; + return true; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to read host customGameData: {Message}", ex.Message); + return false; + } + } + + private static bool TryWriteLocalCustomGameDataJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return false; + + try + { + var relativePath = GetCustomGameDataRelativePath(); + var absolutePath = GetAbsoluteSavePath(relativePath); + var directory = System.IO.Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(directory)) + System.IO.Directory.CreateDirectory(directory); + + // Prefer the game File API so PATH / relative resolution matches CustomGameData.load. + var hlBytes = dc.haxe.io.Bytes.Class.ofString.Invoke(MakeHLString(json), null); + dc.tool.File.Class.saveBytes.Invoke(MakeHLString(relativePath), hlBytes); + _log?.Information( + "[NetMod] Wrote host customGameData ({Length} chars) to {Path}", + json.Length, + relativePath); + return true; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to write customGameData via File API: {Message}", ex.Message); + } + + try + { + var absolutePath = GetAbsoluteSavePath(GetCustomGameDataRelativePath()); + var directory = System.IO.Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(directory)) + System.IO.Directory.CreateDirectory(directory); + System.IO.File.WriteAllText(absolutePath, json); + _log?.Information( + "[NetMod] Wrote host customGameData ({Length} chars) via absolute path", + json.Length); + return true; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to write customGameData: {Message}", ex.Message); + return false; + } + } + + private static void SendCustomGameDataToRemote() + { + var net = NetRef; + if (net == null || !net.IsAlive || !net.IsHost) + return; + + if (!TryReadLocalCustomGameDataJson(out var json)) + { + _log?.Warning("[NetMod] Skipping CGDATA send: local customGameData unavailable"); + return; + } + + net.SendCustomGameData(json); + lock (Sync) + { + // Host already has the file; mark ready so local state stays consistent. + _remoteCustomGameDataReady = true; + _pendingRemoteCustomGameDataJson = json; + } + } + + public static void ReceiveCustomGameData(string? payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + _log?.Warning("[NetMod] Ignoring empty CGDATA payload"); + return; + } + + string json; + try + { + // Wire format is base64 so indented customGameData JSON cannot split the line protocol. + var raw = Convert.FromBase64String(payload.Trim()); + json = System.Text.Encoding.UTF8.GetString(raw); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to decode CGDATA payload: {Message}", ex.Message); + return; + } + + if (string.IsNullOrWhiteSpace(json)) + { + _log?.Warning("[NetMod] Decoded CGDATA payload was empty"); + return; + } + + lock (Sync) + { + _pendingRemoteCustomGameDataJson = json; + } + + // Disk write must happen on the game thread-safe path before auto-start consumes it. + EnqueueCriticalMainThreadCoalesced( + "game:apply-custom-game-data", + () => + { + string? pending; + lock (Sync) + pending = _pendingRemoteCustomGameDataJson; + + if (string.IsNullOrWhiteSpace(pending)) + return; + + if (!TryWriteLocalCustomGameDataJson(pending)) + { + _log?.Warning("[NetMod] Client failed to materialize host customGameData"); + return; + } + + lock (Sync) + { + _remoteCustomGameDataReady = true; + _pendingAutoStart = true; + } + + _log?.Information("[NetMod] Client applied host customGameData ({Length} chars)", pending.Length); + }); + } + + internal static void ClearRemoteCustomGameDataState() + { + lock (Sync) + { + _remoteCustomGameDataReady = false; + _pendingRemoteCustomGameDataJson = null; + } + } + + private static void StartHostRunNormalMode(TitleScreen screen) + { + if (!AllPlayersReady()) + return; + + StartHostServerOnly(); + SetAuthoritativePendingNewGameLaunch( + custom: false, + streamEnabled: TryGetStreamEnabled(screen)); + RememberPendingLaunch( + PendingLaunchAction.NewGame, + custom: false, + TryGetStreamEnabled(screen), + sendToRemote: true); + TryLaunchNewGame(screen, custom: false, TryGetStreamEnabled(screen)); + } + + private static void TryLaunchContinue(TitleScreen? screen) + { + if (!TryBeginContinueLaunch()) + return; + + try + { + ModEntry.Instance?.PrepareForContinueLaunch(); + + var main = dc.Main.Class.ME; + if (main != null) + { + main.launchGame(new LaunchMode.LoadSave(), null, 0.8); + return; + } + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Continue launch failed: {Message}", ex.Message); + ClearContinueLaunchGuard(); + } + + try + { + screen?.saveMenu(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Continue fallback failed: {Message}", ex.Message); + ClearContinueLaunchGuard(); + } + } + + private static bool TryBeginContinueLaunch() + { + var now = DateTime.UtcNow; + lock (Sync) + { + if (_continueLaunchInProgress && + (now - _continueLaunchStartedAt).TotalMilliseconds < ContinueLaunchGuardMs) + { + return false; + } + + _continueLaunchInProgress = true; + _continueLaunchStartedAt = now; + return true; + } + } + + private static void ClearContinueLaunchGuard() + { + lock (Sync) + { + _continueLaunchInProgress = false; + _continueLaunchStartedAt = DateTime.MinValue; + } + } + + private static void TrySendContinueLaunchPrerequisites(TitleScreen? screen) + { + var net = NetRef; + if (net == null || !net.IsAlive || !net.IsHost) + return; + + var user = TryResolveContinueUser(screen); + if (user == null) + return; + + GameDataSync.SendBossRune(user, net); + GameDataSync.SendCurrentHeroCosmetics(user, net, force: true); + } + + private static User? TryResolveContinueUser(TitleScreen? screen) + { + try + { + if (screen?.user != null) + return screen.user; + } + catch + { + } + + try + { + if (dc.Main.Class.ME?.user != null) + return dc.Main.Class.ME.user; + } + catch + { + } + + try + { + return Save.Class.tryLoad.Invoke(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to load selected save for Continue prerequisites: {Message}", ex.Message); + return null; + } + } + + private static void TryLaunchNewGame(TitleScreen? screen, bool custom, bool streamEnabled) + { + SetAuthoritativePendingNewGameLaunch(custom, streamEnabled); + + if (custom && screen != null) + { + // Best-effort TitleScreen prep. Main.getGame reloads User via Save.tryLoad; + // GameDataSync.user_hook_new_game prepares that live instance before GameData ctor. + if (!EnsureCustomModeScreenUser(screen) && _role == NetRole.Host) + { + _log?.Warning( + "[NetMod] Custom Mode launch aborted: TitleScreen user/itemMeta is not ready on host"); + return; + } + } + + // Client Custom Mode: go straight to Main.launchGame. TitleScreen.startNewGame can + // divert into the mods options UI when showModsUI is enabled, which never starts a run. + if (custom && _role == NetRole.Client) + { + var main = dc.Main.Class.ME; + if (main == null) + { + _log?.Warning("[NetMod] Custom Mode client launch aborted: Main is unavailable"); + return; + } + + try + { + if (_structuredLaunchExecuteSequence > 0) + NotifyClientLaunchQueued(_structuredLaunchExecuteSequence); + + _log?.Information( + "[NetMod] Launching NewGame custom={Custom} stream={Stream} role={Role}", + custom, + streamEnabled, + _role); + main.launchGame(new LaunchMode.NewGame(custom, streamEnabled), null, 0.8); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Direct custom-mode client launch failed: {Message}", ex.Message); + } + + return; + } + + if (screen != null) + { + try + { + // Structured RunLaunch barrier: tell the host we are invoking the native loader now. + if (_role == NetRole.Client && _structuredLaunchExecuteSequence > 0) + NotifyClientLaunchQueued(_structuredLaunchExecuteSequence); + + _log?.Information( + "[NetMod] Launching NewGame custom={Custom} stream={Stream} role={Role}", + custom, + streamEnabled, + _role); + screen.startNewGame(custom); + return; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] startNewGame failed, falling back to direct launch: {Message}", ex.Message); + } + } + + var fallbackMain = dc.Main.Class.ME; + if (fallbackMain == null) + return; + + try + { + if (_role == NetRole.Client && _structuredLaunchExecuteSequence > 0) + NotifyClientLaunchQueued(_structuredLaunchExecuteSequence); + + fallbackMain.launchGame(new LaunchMode.NewGame(custom, streamEnabled), null, 0.8); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Direct new-game launch failed: {Message}", ex.Message); + } + } + + private static string BuildGeneratePayloadJson(LevelDescSync? levelDesc) + { + PendingLaunchAction action; + bool custom; + bool streamEnabled; + bool newCoopWorldPrepared; + var coopId = MUser.GetCurrentCoopId() ?? string.Empty; + var hostHasContinueSave = HasLocalContinueSaveState(out _); + lock (Sync) + { + action = _pendingLaunchAction; + custom = _pendingLaunchCustom; + streamEnabled = _pendingLaunchStreamEnabled; + newCoopWorldPrepared = _pendingNewCoopWorldIdAssigned; + } + + var signature = string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{levelDesc?.LevelId}|{levelDesc?.MapDepth}|{levelDesc?.Group}|{(int)action}|{(custom ? 1 : 0)}|{(streamEnabled ? 1 : 0)}|{(newCoopWorldPrepared ? 1 : 0)}|{coopId}|{(hostHasContinueSave ? 1 : 0)}"); + + lock (Sync) + { + if (string.Equals(_cachedGeneratePayloadSignature, signature, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(_cachedGeneratePayloadJson)) + { + return _cachedGeneratePayloadJson!; + } + } + + var payload = new + { + levelDesc = levelDesc ?? new LevelDescSync(), + rawDesc = string.Empty, + launchAction = action.ToString(), + launchCustom = custom, + launchStreamEnabled = streamEnabled, + newCoopWorldPrepared, + coopId, + hostHasContinueSave + }; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(payload); + + lock (Sync) + { + _cachedGeneratePayloadSignature = signature; + _cachedGeneratePayloadJson = json; + } + + return json; + } + + private static void ApplyReceivedPendingLaunch(string? actionText, bool launchCustom, bool launchStreamEnabled) + { + PendingLaunchAction action; + if (!Enum.TryParse(actionText, ignoreCase: true, out action)) + action = PendingLaunchAction.NewGame; + + lock (Sync) + { + _hasAuthoritativePendingNewGameLaunch = action == PendingLaunchAction.NewGame; + _authoritativePendingNewGameCustom = action == PendingLaunchAction.NewGame && launchCustom; + _authoritativePendingNewGameStreamEnabled = action == PendingLaunchAction.NewGame && launchStreamEnabled; + _pendingLaunchAction = action; + _pendingLaunchCustom = launchCustom; + _pendingLaunchStreamEnabled = launchStreamEnabled; + // Opening Custom Mode announces custom=true before CGDATA exists. Clear readiness + // until the host's saved customGameData file arrives. + if (action == PendingLaunchAction.NewGame && launchCustom) + _remoteCustomGameDataReady = false; + else + { + _remoteCustomGameDataReady = true; + _pendingRemoteCustomGameDataJson = null; + } + } + } + + public static void ReceiveLaunchMode( + int actionValue, + bool launchCustom, + bool launchStreamEnabled, + bool newCoopWorldPrepared, + string? coopId, + bool hostHasContinueSave) + { + var action = Enum.IsDefined(typeof(PendingLaunchAction), actionValue) + ? (PendingLaunchAction)actionValue + : PendingLaunchAction.NewGame; + + ApplyReceivedPendingLaunch(action.ToString(), launchCustom, launchStreamEnabled); + + if (!string.IsNullOrWhiteSpace(coopId)) + ReceiveRemoteCoopState(1, coopId, hostHasContinueSave); + + lock (Sync) + { + _receivedLaunchPayload = true; + _receivedNewCoopWorldPrepared = newCoopWorldPrepared; + } + + TryStoreRemoteCoopIdForPendingNewGame(); + RequestLobbyMenuRefresh(); + } + + private static void SendLaunchModeToRemote() + { + var net = NetRef; + if (net == null || !net.IsAlive || !net.IsHost) + return; + + PendingLaunchAction action; + bool custom; + bool streamEnabled; + bool newCoopWorldPrepared; + lock (Sync) + { + action = _pendingLaunchAction; + custom = _pendingLaunchCustom; + streamEnabled = _pendingLaunchStreamEnabled; + newCoopWorldPrepared = _pendingNewCoopWorldIdAssigned; + } + + var coopId = MUser.GetCurrentCoopId() ?? string.Empty; + var hostHasContinueSave = HasLocalContinueSaveState(out _); + net.SendLaunchMode((int)action, custom, streamEnabled, newCoopWorldPrepared, coopId, hostHasContinueSave); + } + + private static bool IsPendingLaunchReadyForAutoStartLocked() + { + if (_pendingLaunchAction == PendingLaunchAction.LoadSave) + { + if (!CanClientAcceptContinueLaunchLocked(out var reason)) + { + LogClientContinueBlockReasonLocked(reason); + return false; + } + + return _genArrived && GameDataSync.HasRemoteBossRune(); + } + + if (!_genArrived || !_seedArrived) + return false; + + if (_pendingLaunchCustom && !_remoteCustomGameDataReady) + return false; + + return IsRemoteRunSyncReadyForLaunchLocked(); + } + + private static bool IsRemoteRunSyncReadyForLaunchLocked() + { + if (!GameDataSync.HasRemoteBossRune()) + return false; + + var levelId = GetCachedLevelDescSync()?.LevelId; + if (!string.IsNullOrWhiteSpace(levelId) && GameDataSync.HasPendingRemoteLevelGraph(levelId)) + return true; + + return GameDataSync.HasPendingRemoteLevelGraph("PrisonStart"); + } + + private static void TryAutoStartPendingLaunch(TitleScreen screen) + { + PendingLaunchAction action; + bool custom; + bool streamEnabled; + lock (Sync) + { + action = _pendingLaunchAction; + custom = _pendingLaunchCustom; + streamEnabled = _pendingLaunchStreamEnabled; + } + + if (action == PendingLaunchAction.LoadSave) + { + TryLaunchContinue(screen); + return; + } + + TryLaunchNewGame(screen, custom, streamEnabled); + } + } +} diff --git a/UI/GameMenu.MultiplayerSaveSlots.cs b/UI/GameMenu.MultiplayerSaveSlots.cs index ab32aba..9e37b87 100644 --- a/UI/GameMenu.MultiplayerSaveSlots.cs +++ b/UI/GameMenu.MultiplayerSaveSlots.cs @@ -31,6 +31,7 @@ private enum MultiplayerSaveMenuKind 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; @@ -62,7 +63,11 @@ private static string GetMultiplayerSaveButtonLabel() private static void OpenMultiplayerSlotMenu(TitleScreen screen) { - _multiplayerSaveMenuReturnRole = _menuSelection; + _multiplayerSaveMenuReturnRole = _inHostStatusMenu + ? NetRole.Host + : _inClientWaitingMenu + ? NetRole.Client + : _role; OpenSaveMenu(screen, MultiplayerSaveMenuKind.MultiplayerSlots); } @@ -165,13 +170,20 @@ private static void Hook_SaveChoice_onValidate(Hook_SaveChoice.orig_onValidate o { if (_multiplayerSaveMenuKind != MultiplayerSaveMenuKind.OriginalSourceSelection) { + int? selectedMultiplayerSlot = null; if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.MultiplayerSlots && TryGetSelectedSaveSlot(self, out var selectedSlot)) { _preferredMultiplayerSaveSlot = selectedSlot; + selectedMultiplayerSlot = selectedSlot; } orig(self); + if (selectedMultiplayerSlot.HasValue) + { + SetCurrentSaveSlot(selectedMultiplayerSlot.Value); + NotifyMultiplayerSaveSlotChanged(); + } return; } @@ -184,6 +196,7 @@ private static void Hook_SaveChoice_onValidate(Hook_SaveChoice.orig_onValidate o _preferredMultiplayerSaveSlot = _multiplayerSaveImportTargetSlot.Value; SetCurrentSaveSlot(_multiplayerSaveImportTargetSlot.Value); + NotifyMultiplayerSaveSlotChanged(); _multiplayerSaveImportTargetSlot = null; SwitchSaveChoiceStore(self, MultiplayerSaveMenuKind.MultiplayerSlots); } @@ -205,7 +218,20 @@ private static void Hook_SaveChoice_onDelete(Hook_SaveChoice.orig_onDelete orig, if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.OriginalSourceSelection) return; + int? deletedMultiplayerSlot = null; + if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.MultiplayerSlots && + TryGetSelectedSaveSlot(self, out var selectedSlot)) + { + deletedMultiplayerSlot = selectedSlot; + } + orig(self); + + if (deletedMultiplayerSlot.HasValue) + { + MUser.ClearCoopId(deletedMultiplayerSlot.Value); + NotifyMultiplayerSaveSlotChanged(); + } } private static void Hook_SaveChoice_onDispose(Hook_SaveChoice.orig_onDispose orig, SaveChoice self) @@ -266,7 +292,7 @@ private static bool ShouldUseMultiplayerSaveStore() if (_multiplayerSaveMenuKind == MultiplayerSaveMenuKind.OriginalSourceSelection) return false; - return _role != NetRole.None || _multiplayerSaveMenuKind == MultiplayerSaveMenuKind.MultiplayerSlots || _multiplayerSaveMenuOpening; + return _forceMultiplayerSaveStore || _role != NetRole.None || _multiplayerSaveMenuKind == MultiplayerSaveMenuKind.MultiplayerSlots || _multiplayerSaveMenuOpening; } private static int ResolveSaveSlotNumber(int? slot) @@ -612,16 +638,9 @@ private static int GetBinding(ArrayBytes_Int? bindings, int actionCode) if (bindings == null) return -1; if ((uint)actionCode >= (uint)bindings.length) - return -1; + return 0; - try - { - return Marshal.ReadInt32(bindings.bytes, actionCode << 2); - } - catch - { - return -1; - } + return Marshal.ReadInt32(bindings.bytes, actionCode << 2); } private static double GetCurrentUnixTimeSeconds() @@ -894,6 +913,7 @@ private static bool CopyOriginalSaveIntoMultiplayerSlot(int sourceSlot, int targ EnsureMultiplayerSaveFolderExists(); var targetRelativePath = GetMultiplayerSaveRelativeFilePath(targetSlot); dc.tool.File.Class.saveBytes.Invoke(MakeHLString(targetRelativePath), sourceBytes); + MUser.ClearCoopId(targetSlot); return true; } catch (Exception ex) diff --git a/UI/GameMenu.Ready.cs b/UI/GameMenu.Ready.cs new file mode 100644 index 0000000..c9af501 --- /dev/null +++ b/UI/GameMenu.Ready.cs @@ -0,0 +1,236 @@ +using System.Globalization; +using dc.pr; +using dc.ui; +using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; + +namespace DeadCellsMultiplayerMod +{ + internal static partial class GameMenu + { + private static void ResetLobbyReadyState() + { + lock (Sync) + { + ResetLobbyReadyStateLocked(); + } + } + + private static void ResetLobbyReadyStateLocked() + { + _localReady = false; + _playersDisplay.Clear(); + } + + private static void ResetLobbyLaunchStateLocked() + { + _inActualRun = false; + _levelDescArrived = false; + _pendingAutoStart = false; + _autoStartTriggered = false; + _pendingClientRestartSeed = null; + _pendingClientRestartReason = string.Empty; + _continueLaunchInProgress = false; + _continueLaunchStartedAt = DateTime.MinValue; + _autoStartRetryAt = DateTime.MinValue; + _genArrived = false; + _seedArrived = false; + _receivedLaunchPayload = false; + _receivedNewCoopWorldPrepared = false; + _remoteCustomGameDataReady = false; + _pendingRemoteCustomGameDataJson = null; + } + + private static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState = false) + { + lock (Sync) + { + ResetLobbyLaunchStateLocked(); + ResetLobbyReadyStateLocked(); + _pendingNewCoopWorldIdAssigned = false; + if (clearRemoteCoopState) + ResetRemoteCoopStateLocked(); + } + } + + private static void ToggleLocalReadyFromMenu(TitleScreen screen) + { + SetLocalReady(!_localReady, sendToRemote: true, refreshMenu: true); + screen.ShouldAutoHideConnectionUI(true); + } + + private static void SetLocalReady(bool ready, bool sendToRemote, bool refreshMenu) + { + if (_localReady == ready && !refreshMenu) + return; + + _localReady = ready; + if (sendToRemote) + SendLocalReadyState(); + if (refreshMenu) + RequestLobbyMenuRefresh(); + } + + private static void SendLocalReadyState() + { + var net = NetRef; + if (net == null || !net.IsAlive || net.id <= 0) + return; + + try + { + net.SendReady(_localReady); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to send ready state: {Message}", ex.Message); + } + } + + internal static void ReceiveRemoteReady(int userId, bool ready) + { + if (userId <= 0) + return; + + RequestLobbyMenuRefresh(); + } + + private static void RequestLobbyMenuRefresh() + { + EnqueueMainThreadCoalesced("ui:lobby-ready-refresh", () => + { + lock (Sync) + { + if (_inActualRun || _autoStartTriggered) + return; + } + + var screen = GetTitleScreen(); + if (screen == null) + return; + + if (_inHostStatusMenu) + { + ShowHostStatusMenu(screen); + return; + } + + if (_inClientWaitingMenu) + ShowClientWaitingMenu(screen); + }); + } + + private static void RefreshPlayersDisplayFromNetwork() + { + _playersDisplay.Clear(); + + var net = NetRef; + var localId = net?.id ?? (_role == NetRole.Host ? 1 : 0); + var localName = string.IsNullOrWhiteSpace(_username) ? "Guest" : _username.Trim(); + if (_role != NetRole.None) + { + _playersDisplay.Add(new PlayerInfo + { + UserId = localId, + Name = localName, + Ready = _localReady, + IsHost = _role == NetRole.Host + }); + } + + if (net == null || !net.IsAlive) + return; + + if (!net.TryGetRemoteUserSnapshots(out var snapshots)) + return; + + try + { + for (var i = 0; i < snapshots.Count; i++) + { + var remote = snapshots[i]; + if (remote.Id <= 0) + continue; + + var ready = false; + net.TryGetRemoteReady(remote.Id, out ready); + + var name = _ConnectionUI.GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); + if (string.IsNullOrWhiteSpace(name) && + remote.Id == 1 && + !string.IsNullOrWhiteSpace(_remoteUsername)) + { + name = _remoteUsername.Trim(); + } + + if (string.IsNullOrWhiteSpace(name)) + name = $"Player {remote.Id}"; + + _playersDisplay.Add(new PlayerInfo + { + UserId = remote.Id, + Name = name, + Ready = ready, + IsHost = remote.Id == 1 + }); + } + } + finally + { + NetNode.ReleaseConsumedList(snapshots); + } + + _playersDisplay.Sort(static (left, right) => + { + if (left.IsHost != right.IsHost) + return left.IsHost ? -1 : 1; + return left.UserId.CompareTo(right.UserId); + }); + } + + internal static bool IsLocalReadyForUi() + { + return _localReady; + } + + internal static string BuildConnectionPlayerDisplayLine(string? name, bool isHost, bool isLocal, bool ready) + { + var safeName = string.IsNullOrWhiteSpace(name) ? "Guest" : name.Trim(); + var tags = string.Empty; + if (isHost) + tags += "(Host)"; + if (isLocal) + tags += "(you)"; + + var readyLabel = ready ? "Ready" : "Not ready"; + return string.Create( + CultureInfo.InvariantCulture, + $"{safeName}{tags} - {readyLabel}"); + } + + private static string GetReadyButtonLabel() + { + return _localReady ? "Ready: On" : "Ready: Off"; + } + + private static string GetPendingLaunchSummaryLabel(TitleScreen? screen) + { + PendingLaunchAction action; + bool custom; + lock (Sync) + { + action = _pendingLaunchAction; + custom = _pendingLaunchCustom; + } + + if (action == PendingLaunchAction.LoadSave) + { + var continueCustom = ResolveCurrentSaveIsCustom(screen); + return string.Create( + CultureInfo.InvariantCulture, + $"Continue ({GetModeLabel(continueCustom)})"); + } + + return GetModeLabel(custom); + } + } +} diff --git a/UI/GameMenu.ReviveInput.cs b/UI/GameMenu.ReviveInput.cs index 2889579..d8d1982 100644 --- a/UI/GameMenu.ReviveInput.cs +++ b/UI/GameMenu.ReviveInput.cs @@ -1,68 +1,18 @@ -using System.Reflection; using System.Runtime.InteropServices; using dc.en; using dc.hl.types; +using dc.hxd; using dc.pr; using dc.tool; +using dc.ui; namespace DeadCellsMultiplayerMod; internal static partial class GameMenu { - private const int ReviveInteractKeyCode = 82; // R (legacy keyboard revive key) + private const int ReviveInteractKeyCode = 82; // R (keyboard) - // SDL_GameControllerButton values used by Dead Cells' pad binding table. This is only a - // last-resort Windows/XInput fallback; the normal path asks the game's own ControllerAccess - // whether the bound interaction action is currently held. - private const ushort XInputDpadUp = 0x0001; - private const ushort XInputDpadDown = 0x0002; - private const ushort XInputDpadLeft = 0x0004; - private const ushort XInputDpadRight = 0x0008; - private const ushort XInputStart = 0x0010; - private const ushort XInputBack = 0x0020; - private const ushort XInputLeftThumb = 0x0040; - private const ushort XInputRightThumb = 0x0080; - private const ushort XInputLeftShoulder = 0x0100; - private const ushort XInputRightShoulder = 0x0200; - private const ushort XInputA = 0x1000; - private const ushort XInputB = 0x2000; - private const ushort XInputX = 0x4000; - private const ushort XInputY = 0x8000; - - private static bool _reviveInputResolutionLogged; - private static bool _reviveInputFallbackLogged; - private static int _reviveXInputBackend; // 0 unknown, 14 xinput1_4, 910 xinput9_1_0, -1 unavailable - - [StructLayout(LayoutKind.Sequential)] - private struct XInputGamepad - { - public ushort Buttons; - public byte LeftTrigger; - public byte RightTrigger; - public short ThumbLX; - public short ThumbLY; - public short ThumbRX; - public short ThumbRY; - } - - [StructLayout(LayoutKind.Sequential)] - private struct XInputState - { - public uint PacketNumber; - public XInputGamepad Gamepad; - } - - [DllImport("xinput1_4.dll", EntryPoint = "XInputGetState")] - private static extern uint XInputGetState14(uint userIndex, out XInputState state); - - [DllImport("xinput9_1_0.dll", EntryPoint = "XInputGetState")] - private static extern uint XInputGetState910(uint userIndex, out XInputState state); - - /// - /// Hold-to-revive input. Keyboard R remains supported. Controller input follows the same - /// gameplay action slot that is bound to R, so controller remapping is respected instead of - /// hard-coding Xbox/PlayStation button numbers. - /// + /// Hold-to-revive: keyboard R plus gamepad face buttons / primary-secondary (same binding resolution as menus). internal static bool IsReviveHoldInputDown(Hero? hero) { if (hero == null) @@ -88,340 +38,71 @@ internal static bool IsReviveHoldInputDown(Hero? hero) var controller = access.parent; if (controller == null || controller.isLocked) return false; - if (controller.exclusiveId != null && controller.exclusiveId != access.id) - return false; - if (!(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0 >= controller.suspendTimer)) - return false; var bindings = controller.get_bindings(); - if (bindings == null) - return false; - var actionCode = ResolveReviveActionCode( - bindings.primary, - bindings.secondary, - bindings.third); - if (actionCode < 0) + bool PadHeld(ArrayBytes_Int? bind) { - LogReviveInputResolutionOnce( - "[NetMod][ReviveInput] Could not resolve the gameplay action bound to keyboard R; " + - "controller revive is unavailable until the interaction binding can be resolved."); - return false; - } + if (bind == null) + return false; + try + { + for (var i = 0; i < bind.length; i++) + { + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code < 0) + continue; + if (controller.padIsDown(code)) + return true; + } + } + catch + { + } - // Preferred path: ControllerAccess understands action state, remapping, Steam Input, - // DirectInput and the active controller. Different game/proxy revisions expose slightly - // different names, so resolve the safe boolean member dynamically. - if (TryReadBooleanActionState(access, actionCode, out var actionHeld)) - return actionHeld; - - // Fallback path: read the physical pad bindings for that same action. Try the game's - // own held-state methods first, then XInput only when exactly one XInput pad is present. - var padA = GetReviveBinding(bindings.padA, actionCode); - var padB = GetReviveBinding(bindings.padB, actionCode); - var padC = GetReviveBinding(bindings.padC, actionCode); - - return IsBoundPadHeld(controller, padA) || - IsBoundPadHeld(controller, padB) || - IsBoundPadHeld(controller, padC); - } - catch (Exception ex) - { - if (!_reviveInputFallbackLogged) - { - _reviveInputFallbackLogged = true; - _log?.Warning("[NetMod][ReviveInput] Controller revive input failed safely: {Message}", ex.Message); - } - } -#pragma warning restore CS8602 - - return false; - } - - private static int ResolveReviveActionCode( - ArrayBytes_Int? primary, - ArrayBytes_Int? secondary, - ArrayBytes_Int? third) - { - var maxLength = Math.Max( - primary?.length ?? 0, - Math.Max(secondary?.length ?? 0, third?.length ?? 0)); - - for (var actionCode = 0; actionCode < maxLength; actionCode++) - { - if (GetReviveBinding(primary, actionCode) != ReviveInteractKeyCode && - GetReviveBinding(secondary, actionCode) != ReviveInteractKeyCode && - GetReviveBinding(third, actionCode) != ReviveInteractKeyCode) - { - continue; - } - - if (!_reviveInputResolutionLogged) - { - _reviveInputResolutionLogged = true; - _log?.Information( - "[NetMod][ReviveInput] Controller revive mapped to gameplay action {ActionCode}", - actionCode); - } - - return actionCode; - } - - return -1; - } - - private static int GetReviveBinding(ArrayBytes_Int? bindings, int actionCode) - { - if (bindings == null || actionCode < 0 || actionCode >= bindings.length) - return -1; - - try - { - return Marshal.ReadInt32(bindings.bytes, actionCode << 2); - } - catch - { - return -1; - } - } - - private static bool TryReadBooleanActionState(object access, int actionCode, out bool value) - { - // isDown is the expected ControllerAccess API. Other names keep this compatible with - // proxy/API revisions without taking a compile-time dependency on an optional member. - return TryInvokeBooleanMember(access, "isDown", actionCode, out value) || - TryInvokeBooleanMember(access, "isHeld", actionCode, out value) || - TryInvokeBooleanMember(access, "down", actionCode, out value) || - TryInvokeBooleanMember(access, "held", actionCode, out value); - } - - private static bool IsBoundPadHeld(Controller controller, int padCode) - { - if (padCode < 0) - return false; - - if (TryInvokeBooleanMember(controller, "padIsDown", padCode, out var held)) - return held; - if (TryInvokeBooleanMember(controller, "padIsHeld", padCode, out held)) - return held; - if (TryInvokeBooleanMember(controller, "isPadDown", padCode, out held)) - return held; - if (TryInvokeBooleanMember(controller, "isPadHeld", padCode, out held)) - return held; - - if (TryReadSingleXInputController(out var state) && - TryMapSdlButtonToXInputMask(padCode, out var buttonMask)) - { - if (!_reviveInputFallbackLogged) - { - _reviveInputFallbackLogged = true; - _log?.Information("[NetMod][ReviveInput] Using safe XInput held-state fallback"); + return false; } - return (state.Gamepad.Buttons & buttonMask) != 0; - } - - // Compatibility fallback for game revisions where padIsPressed reports current state rather - // than an edge. If it is edge-only, it returns false on subsequent frames and therefore - // cannot accidentally complete a hold-to-revive by itself. - try - { - return controller.padIsPressed(padCode); - } - catch - { - return false; - } - } - - private static bool TryInvokeBooleanMember(object target, string memberName, int argument, out bool value) - { - value = false; - if (target == null || string.IsNullOrEmpty(memberName)) - return false; - - try - { - const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - var type = target.GetType(); - - foreach (var method in type.GetMethods(flags)) + bool KeyHeld(ArrayBytes_Int? bind) { - if (!string.Equals(method.Name, memberName, StringComparison.OrdinalIgnoreCase)) - continue; + if (bind == null) + return false; + try + { + for (var i = 0; i < bind.length; i++) + { + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code < 0) + continue; + if (dc.hxd.Key.Class.isDown(code)) + return true; + } + } + catch + { + } - var parameters = method.GetParameters(); - if (parameters.Length != 1) - continue; - - var result = method.Invoke(target, new[] { ConvertReviveInputArgument(argument, parameters[0].ParameterType) }); - if (TryConvertReviveInputBoolean(result, out value)) - return true; - } - - object? callable = type.GetField(memberName, flags)?.GetValue(target) - ?? type.GetProperty(memberName, flags)?.GetValue(target); - if (callable == null) return false; - - if (callable is Delegate del) - { - var result = del.DynamicInvoke(argument); - return TryConvertReviveInputBoolean(result, out value); } - foreach (var invoke in callable.GetType().GetMethods(flags)) - { - if (!string.Equals(invoke.Name, "Invoke", StringComparison.OrdinalIgnoreCase)) - continue; - - var parameters = invoke.GetParameters(); - if (parameters.Length != 1) - continue; - - var result = invoke.Invoke( - callable, - new[] { ConvertReviveInputArgument(argument, parameters[0].ParameterType) }); - if (TryConvertReviveInputBoolean(result, out value)) - return true; - } + if (PadHeld(bindings.padA)) + return true; + if (PadHeld(bindings.padB)) + return true; + if (PadHeld(bindings.padC)) + return true; + if (KeyHeld(bindings.primary)) + return true; + if (KeyHeld(bindings.secondary)) + return true; + if (KeyHeld(bindings.third)) + return true; } catch { } +#pragma warning restore CS8602 return false; } - - private static object ConvertReviveInputArgument(int value, System.Type targetType) - { - if (targetType == typeof(int) || targetType == typeof(object)) - return value; - if (targetType == typeof(uint)) - return unchecked((uint)value); - if (targetType == typeof(short)) - return unchecked((short)value); - if (targetType == typeof(ushort)) - return unchecked((ushort)value); - if (targetType.IsEnum) - return Enum.ToObject(targetType, value); - - return Convert.ChangeType(value, targetType, System.Globalization.CultureInfo.InvariantCulture); - } - - private static bool TryConvertReviveInputBoolean(object? result, out bool value) - { - if (result is bool boolean) - { - value = boolean; - return true; - } - - value = false; - return false; - } - - private static bool TryReadSingleXInputController(out XInputState state) - { - state = default; - var connectedCount = 0; - XInputState candidate = default; - - for (uint index = 0; index < 4; index++) - { - if (!TryXInputGetState(index, out var current)) - continue; - - connectedCount++; - candidate = current; - if (connectedCount > 1) - return false; - } - - if (connectedCount != 1) - return false; - - state = candidate; - return true; - } - - private static bool TryXInputGetState(uint userIndex, out XInputState state) - { - state = default; - - if (_reviveXInputBackend == -1) - return false; - - if (_reviveXInputBackend == 14) - { - try { return XInputGetState14(userIndex, out state) == 0; } - catch { _reviveXInputBackend = 0; } - } - else if (_reviveXInputBackend == 910) - { - try { return XInputGetState910(userIndex, out state) == 0; } - catch { _reviveXInputBackend = 0; } - } - - try - { - var result = XInputGetState14(userIndex, out state); - _reviveXInputBackend = 14; - return result == 0; - } - catch (DllNotFoundException) - { - } - catch (EntryPointNotFoundException) - { - } - catch - { - } - - try - { - var result = XInputGetState910(userIndex, out state); - _reviveXInputBackend = 910; - return result == 0; - } - catch - { - _reviveXInputBackend = -1; - return false; - } - } - - private static bool TryMapSdlButtonToXInputMask(int buttonCode, out ushort mask) - { - mask = buttonCode switch - { - 0 => XInputA, - 1 => XInputB, - 2 => XInputX, - 3 => XInputY, - 4 => XInputBack, - 6 => XInputStart, - 7 => XInputLeftThumb, - 8 => XInputRightThumb, - 9 => XInputLeftShoulder, - 10 => XInputRightShoulder, - 11 => XInputDpadUp, - 12 => XInputDpadDown, - 13 => XInputDpadLeft, - 14 => XInputDpadRight, - _ => 0 - }; - - return mask != 0; - } - - private static void LogReviveInputResolutionOnce(string message) - { - if (_reviveInputResolutionLogged) - return; - - _reviveInputResolutionLogged = true; - _log?.Warning(message); - } } diff --git a/UI/GameMenu.RunLaunch.cs b/UI/GameMenu.RunLaunch.cs index a754f43..56c91b7 100644 --- a/UI/GameMenu.RunLaunch.cs +++ b/UI/GameMenu.RunLaunch.cs @@ -216,7 +216,25 @@ private static void QueueInitialHostLaunchExecution( _initialHostLaunchPendingSequence = 0; } - screen.startNewGame(custom: false); + bool custom; + bool streamEnabled; + lock (Sync) + { + custom = _pendingLaunchCustom; + streamEnabled = _pendingLaunchStreamEnabled; + } + + SetAuthoritativePendingNewGameLaunch(custom, streamEnabled); + if (custom && !EnsureCustomModeScreenUser(screen)) + { + CancelPrecommittedHostRunSeed("custom_mode_user_unready"); + CancelHostStructuredLaunch(descriptor.Sequence, "custom_mode_user_unready"); + MultiplayerUI.PushSystemMessage(Localize("Custom Mode could not prepare the save user.")); + ShowHostStatusMenu(screen); + return; + } + + screen.startNewGame(custom); } catch (Exception ex) { diff --git a/UI/GameMenu.RunLaunchCompat.cs b/UI/GameMenu.RunLaunchCompat.cs new file mode 100644 index 0000000..ef08483 --- /dev/null +++ b/UI/GameMenu.RunLaunchCompat.cs @@ -0,0 +1,627 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Threading; +using System.Threading.Channels; +using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; +using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; +using DeadCellsMultiplayerMod.PortableCore; +using ModCore.Modules; + +namespace DeadCellsMultiplayerMod; + +/// +/// Restores features-continue RunLaunch / main-thread network APIs on top of the +/// checked-out dev GameMenu lobby/UI base. +/// +internal static partial class GameMenu +{ + private static int _serverSeedSequence; + private static int _remoteSeedSequence; + private static int _consumedRemoteSeedSequence; + private static string _remoteLaunchKind = string.Empty; + + private const int RemoteRunSeedWaitMs = 2000; + private const int RunSeedTransitionGraceMs = 2000; + + private static readonly Channel _networkMainThreadQueue = Channel.CreateBounded( + new BoundedChannelOptions(2048) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + AllowSynchronousContinuations = false + }); + + private static readonly object CriticalMainThreadCoalesceSync = new(); + private static readonly Dictionary _criticalCoalescedActions = new(StringComparer.Ordinal); + private static readonly ConcurrentQueue _criticalCoalescedKeys = new(); + private const int MainThreadQueueMaxPendingCritical = 64; + private const int MainThreadQueueBurstActionsPerPump = 768; + private const int MainThreadQueueBurstBacklogThreshold = 96; + private static long _lastMainThreadCoalescedDropLogTicks; + + private static long _clientRestartPendingUntilTicks; + private 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; + + private static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; + private static bool _protocolMismatchPending; + + private static void ResetRunLaunchCompatStateLocked() + { + _serverSeedSequence = 0; + _remoteSeedSequence = 0; + _consumedRemoteSeedSequence = 0; + _remoteLaunchKind = string.Empty; + ClearStructuredLaunchFlagsLocked(); + ClearPrecommittedHostRunSeedLocked(); + _clientRestartPendingUntilTicks = 0; + _protocolMismatchPending = false; + while (_networkMainThreadQueue.Reader.TryRead(out _)) { } + while (_criticalCoalescedKeys.TryDequeue(out _)) { } + lock (CriticalMainThreadCoalesceSync) + _criticalCoalescedActions.Clear(); + } + + internal static ValueTask EnqueueNetworkMainThreadAsync(Action action, CancellationToken cancellationToken) + { + if (action == null) + return ValueTask.CompletedTask; + + return _networkMainThreadQueue.Writer.WriteAsync(action, cancellationToken); + } + + internal static void ClearPendingNetworkMainThreadActions() + { + while (_networkMainThreadQueue.Reader.TryRead(out _)) { } + } + + internal static void EnqueueCriticalMainThreadCoalesced(string coalesceKey, Action action) + { + if (action == null || string.IsNullOrWhiteSpace(coalesceKey)) + return; + + bool isNewKey; + lock (CriticalMainThreadCoalesceSync) + { + isNewKey = !_criticalCoalescedActions.ContainsKey(coalesceKey); + if (isNewKey && _criticalCoalescedActions.Count >= MainThreadQueueMaxPendingCritical) + { + LogCriticalMainThreadCoalescedDropRateLimited(coalesceKey); + return; + } + + _criticalCoalescedActions[coalesceKey] = action; + } + + if (isNewKey) + _criticalCoalescedKeys.Enqueue(coalesceKey); + } + + private static void LogCriticalMainThreadCoalescedDropRateLimited(string key) + { + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + var minTicks = System.Diagnostics.Stopwatch.Frequency * 5L; + var previous = Interlocked.Read(ref _lastMainThreadCoalescedDropLogTicks); + if (previous != 0 && now - previous < minTicks) + return; + if (Interlocked.CompareExchange(ref _lastMainThreadCoalescedDropLogTicks, now, previous) != previous) + return; + + _log?.Warning( + "[NetMod] Rejected critical coalesced main-thread work because its queue is full (key={Key})", + key); + } + + /// + /// Drain critical coalesced work first, then reliable network protocol actions. + /// Called from so receive-loop back-pressure stays healthy. + /// + private static int DrainCriticalAndNetworkMainThreadQueues(int budget) + { + if (budget <= 0) + return 0; + + var processed = 0; + var networkBacklog = 0; + if (_networkMainThreadQueue.Reader.CanCount) + networkBacklog = _networkMainThreadQueue.Reader.Count; + + var effectiveBudget = networkBacklog >= MainThreadQueueBurstBacklogThreshold + ? Math.Max(budget, MainThreadQueueBurstActionsPerPump) + : budget; + + while (processed < effectiveBudget) + { + Action? action = null; + + if (_criticalCoalescedKeys.TryDequeue(out var criticalKey)) + { + lock (CriticalMainThreadCoalesceSync) + { + _criticalCoalescedActions.TryGetValue(criticalKey, out action); + _criticalCoalescedActions.Remove(criticalKey); + } + } + else if (_networkMainThreadQueue.Reader.TryRead(out var networkAction)) + { + action = networkAction; + } + else + { + break; + } + + if (action == null) + continue; + + processed++; + try + { + action(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); + } + } + + return processed; + } + + internal static void MarkClientRestartPending() + { + Volatile.Write(ref _clientRestartPendingUntilTicks, Environment.TickCount64 + ClientRestartPendingTtlMs); + } + + internal static void ClearClientRestartPending() + { + Volatile.Write(ref _clientRestartPendingUntilTicks, 0); + } + + internal static bool IsClientRestartPending() + { + var until = Volatile.Read(ref _clientRestartPendingUntilTicks); + return until != 0 && Environment.TickCount64 < until; + } + + public static int RegisterHostRunSeed(int seed, string launchKind, string reason) + { + int sequence; + lock (Sync) + { + _serverSeed = seed; + sequence = _serverSeedSequence == int.MaxValue ? 1 : _serverSeedSequence + 1; + _serverSeedSequence = sequence; + } + + _log?.Information( + "[NetMod] Registered host run seed seq={Sequence} seed={Seed} launch={LaunchKind} ({Reason})", + sequence, + seed, + launchKind ?? string.Empty, + reason); + return sequence; + } + + internal static bool PrecommitInitialHostRunSeed(out int seed, out int sequence, out RunLaunchDescriptor? descriptor) + { + seed = 0; + sequence = 0; + descriptor = null; + + var net = NetRef; + if (net == null || !net.IsAlive || !net.IsHost) + return false; + + const string launchKind = "dc.LaunchMode+NewGame"; + + seed = ForceGenerateServerSeed("title.startNewGame_precommit"); + sequence = RegisterHostRunSeed(seed, launchKind, "title.startNewGame_precommit"); + + lock (Sync) + { + _precommittedHostSeed = seed; + _precommittedHostSeedSequence = sequence; + _precommittedHostLaunchKind = launchKind; + _precommittedHostSeedExpiresAtTicks = Environment.TickCount64 + PrecommittedHostSeedTtlMs; + } + + descriptor = BuildHostRunLaunchDescriptor(seed, sequence, launchKind); + net.SendRunLaunchCommit(descriptor, flush: true); + net.SendSeed(sequence, seed, launchKind); + net.SendControlAndFlush($"SEED|{sequence}|{seed}|{launchKind}", 500); + _log?.Information( + "[NetMod] Precommitted initial host run seq={Sequence} seed={Seed} launch={LaunchKind}", + sequence, + seed, + launchKind); + return true; + } + + internal static bool PrecommitHostBossRushRunSeed( + string bossRushType, + int doorCx, + int doorCy, + out int seed, + out int sequence) + { + const string launchKind = "dc.LaunchMode+BossRush"; + seed = 0; + sequence = 0; + + var net = NetRef; + if (net == null || !net.IsAlive || !net.IsHost) + return false; + + lock (Sync) + { + var expired = _precommittedHostSeedExpiresAtTicks != 0 && + Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; + if (expired) + ClearPrecommittedHostRunSeedLocked(); + + if (_precommittedHostSeed.HasValue && + _precommittedHostSeedSequence > 0 && + GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind)) + { + seed = _precommittedHostSeed.Value; + sequence = _precommittedHostSeedSequence; + } + } + + if (sequence <= 0) + { + seed = ForceGenerateServerSeed("bossrush_door_precommit"); + sequence = RegisterHostRunSeed(seed, launchKind, "bossrush_door_precommit"); + + lock (Sync) + { + _precommittedHostSeed = seed; + _precommittedHostSeedSequence = sequence; + _precommittedHostLaunchKind = launchKind; + _precommittedHostSeedExpiresAtTicks = Environment.TickCount64 + PrecommittedHostSeedTtlMs; + } + } + + try + { + CommitHostRunLaunchFromHook(seed, sequence, launchKind, bossRushType); + net.SendSeed(sequence, seed, launchKind); + net.SendControlAndFlush($"SEED|{sequence}|{seed}|{launchKind}", 500); + _log?.Information( + "[NetMod][BossRushSeed] Precommitted seq={Sequence} seed={Seed} type={BossRushType} door={DoorCx}:{DoorCy}", + sequence, + seed, + string.IsNullOrWhiteSpace(bossRushType) ? "unknown" : bossRushType, + doorCx, + doorCy); + return true; + } + catch (Exception ex) + { + _log?.Warning( + "[NetMod][BossRushSeed] Failed to precommit Boss Rush seed at door={DoorCx}:{DoorCy}: {Message}", + doorCx, + doorCy, + ex.Message); + return false; + } + } + + internal static bool HasPrecommittedHostBossRushLaunch() + { + lock (Sync) + { + var expired = _precommittedHostSeedExpiresAtTicks != 0 && + Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; + if (expired) + ClearPrecommittedHostRunSeedLocked(); + + return _precommittedHostSeed.HasValue && + _precommittedHostSeedSequence > 0 && + GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind); + } + } + + internal static bool HasPendingRemoteBossRushLaunch() + { + var descriptor = RunLaunchCoordinator.GetCurrentRemoteDescriptor(); + if (descriptor == null || !descriptor.BossRush) + return false; + + lock (Sync) + { + return descriptor.Sequence > _consumedRemoteSeedSequence && + _remoteSeedSequence == descriptor.Sequence && + RunLaunchCoordinator.HasExecutableRemoteLaunch(descriptor.Sequence); + } + } + + internal static bool TryGetPendingRemoteBossRushSeed(out int seed) + { + lock (Sync) + { + if (_remoteSeed.HasValue && + _remoteSeedSequence > _consumedRemoteSeedSequence && + GameDataSync.IsBossRushLaunchKind(_remoteLaunchKind)) + { + seed = _remoteSeed.Value; + return true; + } + } + + seed = 0; + return false; + } + + internal static bool TryConsumePrecommittedHostRunSeed( + string launchKind, + out int seed, + out int sequence) + { + lock (Sync) + { + var expired = _precommittedHostSeedExpiresAtTicks != 0 && + Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; + if (expired) + ClearPrecommittedHostRunSeedLocked(); + + if (!_precommittedHostSeed.HasValue || _precommittedHostSeedSequence <= 0) + { + seed = 0; + sequence = 0; + return false; + } + + var requestedNewGame = !string.IsNullOrWhiteSpace(launchKind) && + launchKind.Contains("NewGame", StringComparison.OrdinalIgnoreCase); + var stagedNewGame = !string.IsNullOrWhiteSpace(_precommittedHostLaunchKind) && + _precommittedHostLaunchKind.Contains("NewGame", StringComparison.OrdinalIgnoreCase); + var requestedBossRush = GameDataSync.IsBossRushLaunchKind(launchKind); + var stagedBossRush = GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind); + if (!string.Equals(launchKind, _precommittedHostLaunchKind, StringComparison.Ordinal) && + !(requestedNewGame && stagedNewGame) && + !(requestedBossRush && stagedBossRush)) + { + seed = 0; + sequence = 0; + return false; + } + + seed = _precommittedHostSeed.Value; + sequence = _precommittedHostSeedSequence; + ClearPrecommittedHostRunSeedLocked(); + return true; + } + } + + internal static void CancelPrecommittedHostRunSeed(string reason = "precommitted_launch_cancelled") + { + int sequence; + lock (Sync) + { + sequence = _precommittedHostSeedSequence; + ClearPrecommittedHostRunSeedLocked(); + } + + if (sequence > 0) + CancelHostStructuredLaunch(sequence, reason); + } + + private static void ClearPrecommittedHostRunSeedLocked() + { + _precommittedHostSeed = null; + _precommittedHostSeedSequence = 0; + _precommittedHostLaunchKind = string.Empty; + _precommittedHostSeedExpiresAtTicks = 0; + } + + public static bool TryGetKnownSeed(out int seed) + { + lock (Sync) + { + if (_serverSeed.HasValue) + { + seed = _serverSeed.Value; + return true; + } + + if (_remoteSeed.HasValue) + { + seed = _remoteSeed.Value; + return true; + } + } + + seed = 0; + return false; + } + + /// + /// Protocol 17 seed receive path used by the text NetNode. Keeps the legacy 1-arg + /// for older same-run restart flows. + /// + public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) + { + var scheduleInRunReconcile = false; + lock (Sync) + { + if (sequence <= 0) + return; + + if (sequence < _remoteSeedSequence) + return; + + if (sequence == _remoteSeedSequence) + { + if (_remoteSeed == seed) + Monitor.PulseAll(Sync); + return; + } + + _remoteSeed = seed; + _remoteSeedSequence = sequence; + _remoteLaunchKind = launchKind ?? string.Empty; + if (_role == NetRole.Client) + { + var isBossRushSeed = GameDataSync.IsBossRushLaunchKind(launchKind); + if (_inActualRun) + { + scheduleInRunReconcile = sequence > _consumedRemoteSeedSequence && !isBossRushSeed; + } + else + { + _seedArrived = true; + if (!isBossRushSeed && CanAutoStartStructuredClientLaunchLocked()) + _pendingAutoStart = true; + } + } + + Monitor.PulseAll(Sync); + } + + _log?.Information( + "[NetMod] Client received host run seed seq={Sequence} seed={Seed} launch={LaunchKind}", + sequence, + seed, + launchKind ?? string.Empty); + + if (scheduleInRunReconcile) + ScheduleClientRunSeedReconcile(sequence, seed); + } + + private static void ScheduleClientRunSeedReconcile(int sequence, int seed) + { + _ = Task.Run(async () => + { + await Task.Delay(RunSeedTransitionGraceMs).ConfigureAwait(false); + EnqueueCriticalMainThreadCoalesced("game:run-seed-reconcile", () => + { + var shouldRestart = false; + lock (Sync) + { + if (_role == NetRole.Client && + _inActualRun && + _remoteSeedSequence == sequence && + _consumedRemoteSeedSequence < sequence) + { + _inActualRun = false; + _pendingAutoStart = false; + _autoStartTriggered = false; + shouldRestart = true; + } + } + + if (shouldRestart) + QueueClientRestartFromHostSeed(seed, $"unconsumed_host_launch_seq_{sequence}"); + }); + }); + } + + public static bool TryConsumeNextRemoteRunSeed(out int seed, out int sequence, out string launchKind) + { + if (RunLaunchCoordinator.TryConsumeRemoteLaunch( + RemoteRunSeedWaitMs, + out var descriptor, + out var error) && + descriptor != null) + { + seed = descriptor.RunSeed; + sequence = descriptor.Sequence; + launchKind = descriptor.LaunchKind; + lock (Sync) + { + _remoteSeed = seed; + _remoteSeedSequence = sequence; + _remoteLaunchKind = launchKind; + _consumedRemoteSeedSequence = sequence; + _seedArrived = true; + Monitor.PulseAll(Sync); + } + + return true; + } + + _log?.Error("[NetMod][RunLaunch] {Error}", error); + seed = 0; + sequence = 0; + launchKind = string.Empty; + return false; + } + + public static void RefreshRoomStatusMenuIfVisible() + { + if (!_inHostStatusMenu && !_inClientWaitingMenu) + return; + if ((DateTime.UtcNow - _lastRoomStatusAutoRefresh).TotalSeconds < 1.0) + return; + _lastRoomStatusAutoRefresh = DateTime.UtcNow; + + EnqueueMainThreadCoalesced("ui:auto-refresh-room-status", () => + { + var screen = GetTitleScreen(); + if (screen == null) + return; + if (_inHostStatusMenu) + ShowHostStatusMenu(screen); + else if (_inClientWaitingMenu) + ShowClientWaitingMenu(screen); + }); + } + + internal static void NotifyProtocolMismatch( + string remoteBuild, + int remoteProtocol, + string localBuild, + int localProtocol, + NetRole localRole) + { + var remoteLabel = string.IsNullOrWhiteSpace(remoteBuild) ? "unknown" : remoteBuild.Trim(); + var detail = string.Create( + CultureInfo.InvariantCulture, + $"Other player: {remoteLabel} (protocol {remoteProtocol}). You: {localBuild} (protocol {localProtocol})."); + + MultiplayerUI.PushSystemMessage( + "Co-op version mismatch. Both players need the exact same mod build.", + 8.0, + 1.5); + ConnectionUI.NotifyConnectionsChanged(); + + if (localRole != NetRole.Client) + return; + + lock (Sync) + { + _protocolMismatchPending = true; + _clientConnecting = false; + _waitingForHost = false; + } + + 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, + Localize("Install the exact same DeadCellsMultiplayerMod build on both computers."), + 0xE0E0E0); + AddMenuButton(screen, GetText.Instance.GetString("OK"), () => + { + screen.clearMenu(); + ShowJoinTransportMenu(screen); + }, Localize("Return to join menu")); + screen.ShouldAutoHideConnectionUI(false); + }); + } +} diff --git a/UI/GameMenu.cs b/UI/GameMenu.cs index 1a1e734..3ea4090 100644 --- a/UI/GameMenu.cs +++ b/UI/GameMenu.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Channels; +using System.Globalization; +using System.Reflection; using dc.pr; using dc.ui; using Newtonsoft.Json; @@ -9,8 +9,8 @@ using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using DeadCellsMultiplayerMod.PortableCore; +using DeadCellsMultiplayerMod.Tools; using ModCore.Modules; -using HaxeProxy.Runtime; namespace DeadCellsMultiplayerMod @@ -23,62 +23,39 @@ internal static partial class GameMenu private static bool _inActualRun; private static int? _serverSeed; private static int? _remoteSeed; - private static int _serverSeedSequence; - private static int _remoteSeedSequence; - private static int _consumedRemoteSeedSequence; - private static string _remoteLaunchKind = string.Empty; - // Protocol 17: the launch is gated so the authoritative seed is present before newGame runs. - // This wait is now only a short scheduling tolerance, not a 10s main-thread barrier. - private const int RemoteRunSeedWaitMs = 2000; - private const int RunSeedTransitionGraceMs = 2000; + private static int? _pendingClientRestartSeed; + private static string _pendingClientRestartReason = string.Empty; private const int MaxSeed = 999_999; public static NetNode? NetRef { get; set; } - private static readonly ConcurrentQueue _mainThreadQueue = new(); - // Network protocol work must never be silently dropped: losing a death, level, revive, or - // interaction message creates permanent host/client divergence. A bounded channel applies - // back-pressure to the receive loop while the game thread is loading or paused. - private static readonly Channel _networkMainThreadQueue = Channel.CreateBounded( - new BoundedChannelOptions(2048) - { - SingleReader = true, - SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait, - AllowSynchronousContinuations = false - }); + private readonly struct MainThreadWorkItem + { + public readonly Action? Action; + public readonly string? CoalesceKey; + + public MainThreadWorkItem(Action action) + { + Action = action; + CoalesceKey = null; + } + + public MainThreadWorkItem(string coalesceKey) + { + Action = null; + CoalesceKey = coalesceKey; + } + } + + private static readonly ConcurrentQueue _mainThreadQueue = new(); + private static readonly ConcurrentDictionary _mainThreadActionLabelCache = new(); private static readonly object MainThreadCoalesceSync = new(); - private static readonly Dictionary _coalescedActions = new(StringComparer.Ordinal); - private static readonly ConcurrentQueue _coalescedKeys = new(); - // Death/revive/restart/session transitions must not wait behind a continuous stream of - // visual/network work. Critical actions are coalesced by fixed keys and get first chance - // during each pump, while still remaining bounded. - private static readonly object CriticalMainThreadCoalesceSync = new(); - private static readonly Dictionary _criticalCoalescedActions = new(StringComparer.Ordinal); - private static readonly ConcurrentQueue _criticalCoalescedKeys = new(); - private const int MainThreadQueueMaxActionsPerPump = 128; - // ROOT-CAUSE FIX (online enemy freeze): every received MOBMOVE/MOBSTATE line is applied on - // the game thread via _networkMainThreadQueue, and the receive loop AWAITS each enqueue - // (bounded channel, FullMode=Wait). Two local instances share a machine, so the queue - // drains as fast as it fills and never backs up. Over a real 50-200ms link the packets - // arrive in bursts after jitter/stalls, the per-frame drain of 128 can't keep up with a - // busy room's move stream, the bounded channel fills, and back-pressure stalls the - // receiver — so movement/state updates stop being applied and enemies freeze on the client - // even though HP/death (rare, coalesced) still land. The drain budget must scale with the - // backlog so a burst is absorbed within a frame or two instead of accumulating. - private const int MainThreadQueueBurstActionsPerPump = 768; - /// Backlog in the reliable protocol queue above which the pump switches to the burst budget. - private const int MainThreadQueueBurstBacklogThreshold = 96; - private const int MainThreadQueueMaxPendingDirect = 512; - private const int MainThreadQueueMaxPendingCoalesced = 512; - private const int MainThreadQueueMaxPendingCritical = 64; - private static int _mainThreadDirectQueueCount; - private static long _lastMainThreadQueueDropLogTicks; - private static long _lastMainThreadCoalescedDropLogTicks; + private static readonly Dictionary _coalescedMainThreadActions = new(StringComparer.Ordinal); + private static readonly HashSet _pendingCoalescedMainThreadKeys = new(StringComparer.Ordinal); + private static int _mainThreadQueueDepth; + private const int MainThreadQueueMaxActionsPerPump = 64; + private const double MainThreadQueueBudgetMs = 4.0; private static bool _menuHooksAttached; private static bool _addMenuHookRegistered; - private static bool _mainMenuButtonAdded; - private static bool _addingMultiplayerButton; - private const int MultiplayerMainMenuTextColor = 0x7FD4FF; // soft blue private static WeakReference? _titleScreenRef; private static string _mpIp = "127.0.0.1"; private static int _mpPort = 1234; @@ -94,40 +71,35 @@ private enum ConnectionTransport private static string _steamLobbyCode = string.Empty; private static ulong _steamHostSteamId; private static bool _steamJoinLobbyResolvePending; - private static int _steamJoinResolveGeneration; private static ulong? _pendingOverlayJoinLobbyId; private static bool _waitingForHost; - private static int _roomStatusMenuKind; // 0 none, 1 host, 2 client - private static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; internal const int ClientConnectMaxAttempts = 3; private static int _clientConnectAttempt; private static bool _clientConnecting; private static bool _pendingAutoStart; private static bool _levelDescArrived; 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; - // While a client full-run restart (from host seed) is pending, the host's freshly broadcast level - // graph for the restart level must NOT trigger an in-place reloadAfterBossRuneModif on the client: - // that collides with the queued launchGame restart and leaves the old downed hero / Game Over stuck. - private static long _clientRestartPendingUntilTicks; - private const int ClientRestartPendingTtlMs = 12000; 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; - // The title-screen Start button can enter the opening cinematic before User.newGame is - // invoked. Precommit and broadcast the initial seed here so connected clients can leave - // the lobby immediately instead of waiting for the host cinematic to finish. - private static int? _precommittedHostSeed; - private static int _precommittedHostSeedSequence; - private static string _precommittedHostLaunchKind = string.Empty; - private static long _precommittedHostSeedExpiresAtTicks; - private const int PrecommittedHostSeedTtlMs = 300000; private static string _username = "guest"; private static string _remoteUsername = "guest"; private static string _playerId = Guid.NewGuid().ToString("N"); @@ -154,8 +126,15 @@ internal static bool TryCopySteamLobbyCodeFromUi() return SteamConnect.TryCopyLobbyCodeToClipboard(code); } - /// True while clipboard/overlay join is resolving the Steam lobby. + /// 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; + /// 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; @@ -195,7 +174,6 @@ public static void Initialize(ILogger logger) { logger.Information("\x1b[32m[[ModEntry.GameMenu] Initializing GameMenu...]\x1b[0m "); InitializeRunLaunchHandshake(logger); - RunMultiplayerSaveStartupRecovery(logger); lock (Sync) { _log = logger; @@ -203,42 +181,48 @@ public static void Initialize(ILogger logger) _inActualRun = false; _serverSeed = null; _remoteSeed = null; - _serverSeedSequence = 0; - _remoteSeedSequence = 0; - _consumedRemoteSeedSequence = 0; - _remoteLaunchKind = string.Empty; + _pendingClientRestartSeed = null; + _pendingClientRestartReason = string.Empty; _levelDescArrived = false; _pendingAutoStart = false; _autoStartTriggered = false; + _continueLaunchInProgress = false; + _continueLaunchStartedAt = DateTime.MinValue; + _genArrived = false; _seedArrived = false; - ClearStructuredLaunchFlagsLocked(); - _precommittedHostSeed = null; - _precommittedHostSeedSequence = 0; - _precommittedHostLaunchKind = string.Empty; - _precommittedHostSeedExpiresAtTicks = 0; _clientConnectAttempt = 0; _clientConnecting = false; _deathRestartCooldownUntil = DateTime.MinValue; _cachedLevelDescSync = null; _hostDisconnectCountdownActive = false; + _hostDisconnectCountdownGameRef = null; _hostDisconnectCountdownUntil = DateTime.MinValue; _lastHostDisconnectCountdown = -1; + _hostDisconnectSavePending = false; + _hostDisconnectSaveRetryAt = DateTime.MinValue; + _hostDisconnectSaveDeadline = DateTime.MinValue; _menuTransport = ConnectionTransport.Lan; _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; - _steamJoinLobbyResolvePending = false; - Interlocked.Increment(ref _steamJoinResolveGeneration); - while (_mainThreadQueue.TryDequeue(out _)) { } - while (_networkMainThreadQueue.Reader.TryRead(out _)) { } - Interlocked.Exchange(ref _mainThreadDirectQueueCount, 0); - while (_coalescedKeys.TryDequeue(out _)) { } + _pendingLaunchAction = PendingLaunchAction.NewGame; + _pendingLaunchCustom = false; + _pendingLaunchStreamEnabled = false; + _hasAuthoritativePendingNewGameLaunch = false; + _authoritativePendingNewGameCustom = false; + _authoritativePendingNewGameStreamEnabled = false; + ResetRemoteCoopStateLocked(); + _receivedNewCoopWorldPrepared = false; + ResetLobbyReadyStateLocked(); + InvalidateGeneratePayloadCacheLocked(); + ResetRunLaunchCompatStateLocked(); + _mainThreadQueueDepth = _mainThreadQueue.Count; lock (MainThreadCoalesceSync) - _coalescedActions.Clear(); - while (_criticalCoalescedKeys.TryDequeue(out _)) { } - lock (CriticalMainThreadCoalesceSync) - _criticalCoalescedActions.Clear(); + { + _coalescedMainThreadActions.Clear(); + _pendingCoalescedMainThreadKeys.Clear(); + } } InitializeMenuUiHooks(); @@ -247,46 +231,8 @@ public static void Initialize(ILogger logger) internal static void EnqueueMainThread(Action action) { if (action == null) return; - - var pending = Interlocked.Increment(ref _mainThreadDirectQueueCount); - if (pending > MainThreadQueueMaxPendingDirect) - { - Interlocked.Decrement(ref _mainThreadDirectQueueCount); - LogMainThreadQueueDropRateLimited(pending); - return; - } - - _mainThreadQueue.Enqueue(action); - } - - - internal static ValueTask EnqueueNetworkMainThreadAsync(Action action, CancellationToken cancellationToken) - { - if (action == null) - return ValueTask.CompletedTask; - - return _networkMainThreadQueue.Writer.WriteAsync(action, cancellationToken); - } - - internal static void ClearPendingNetworkMainThreadActions() - { - while (_networkMainThreadQueue.Reader.TryRead(out _)) { } - } - - private static void LogMainThreadQueueDropRateLimited(int pending) - { - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - var minTicks = System.Diagnostics.Stopwatch.Frequency * 5L; - var previous = Interlocked.Read(ref _lastMainThreadQueueDropLogTicks); - if (previous != 0 && now - previous < minTicks) - return; - if (Interlocked.CompareExchange(ref _lastMainThreadQueueDropLogTicks, now, previous) != previous) - return; - - _log?.Warning( - "[NetMod] Dropped main-thread work because the direct queue exceeded {MaxPending} actions (observed={Pending})", - MainThreadQueueMaxPendingDirect, - pending); + _mainThreadQueue.Enqueue(new MainThreadWorkItem(action)); + Interlocked.Increment(ref _mainThreadQueueDepth); } internal static void EnqueueMainThreadCoalesced(string coalesceKey, Action action) @@ -300,133 +246,47 @@ internal static void EnqueueMainThreadCoalesced(string coalesceKey, Action actio return; } - bool isNewKey; + var shouldEnqueue = false; lock (MainThreadCoalesceSync) { - isNewKey = !_coalescedActions.ContainsKey(coalesceKey); - if (isNewKey && _coalescedActions.Count >= MainThreadQueueMaxPendingCoalesced) - { - LogMainThreadCoalescedDropRateLimited(coalesceKey, critical: false); - return; - } - _coalescedActions[coalesceKey] = action; + _coalescedMainThreadActions[coalesceKey] = action; + if (_pendingCoalescedMainThreadKeys.Add(coalesceKey)) + shouldEnqueue = true; } - if (isNewKey) - _coalescedKeys.Enqueue(coalesceKey); - } - - internal static void EnqueueCriticalMainThreadCoalesced(string coalesceKey, Action action) - { - if (action == null || string.IsNullOrWhiteSpace(coalesceKey)) + if (!shouldEnqueue) return; - bool isNewKey; - lock (CriticalMainThreadCoalesceSync) - { - isNewKey = !_criticalCoalescedActions.ContainsKey(coalesceKey); - if (isNewKey && _criticalCoalescedActions.Count >= MainThreadQueueMaxPendingCritical) - { - LogMainThreadCoalescedDropRateLimited(coalesceKey, critical: true); - return; - } - _criticalCoalescedActions[coalesceKey] = action; - } - - if (isNewKey) - _criticalCoalescedKeys.Enqueue(coalesceKey); - } - - private static void LogMainThreadCoalescedDropRateLimited(string key, bool critical) - { - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - var minTicks = System.Diagnostics.Stopwatch.Frequency * 5L; - var previous = Interlocked.Read(ref _lastMainThreadCoalescedDropLogTicks); - if (previous != 0 && now - previous < minTicks) - return; - if (Interlocked.CompareExchange(ref _lastMainThreadCoalescedDropLogTicks, now, previous) != previous) - return; - - _log?.Warning( - "[NetMod] Rejected {Kind} coalesced main-thread work because its queue is full (key={Key})", - critical ? "critical" : "normal", - key); + _mainThreadQueue.Enqueue(new MainThreadWorkItem(coalesceKey)); + Interlocked.Increment(ref _mainThreadQueueDepth); } internal static void ProcessMainThreadQueue() { + var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; + var startDepth = Volatile.Read(ref _mainThreadQueueDepth); var processed = 0; + var slowActions = 0; + var maxActionMs = 0.0; + var maxActionLabel = string.Empty; + var actionsStart = RuntimeHitchWatch.Start(); - // Adaptive budget: when the reliable protocol queue has backed up (bursty arrival after - // real-network jitter/stalls), drain far more this frame so the bounded channel does not - // stay full and apply back-pressure to the receive loop — the stall that froze client - // enemies online. A quiet queue keeps the small default budget so we never spend frame - // time we don't need. - var networkBacklog = 0; - if (_networkMainThreadQueue.Reader.CanCount) - networkBacklog = _networkMainThreadQueue.Reader.Count; - var budget = networkBacklog >= MainThreadQueueBurstBacklogThreshold - ? MainThreadQueueBurstActionsPerPump - : MainThreadQueueMaxActionsPerPump; - - if (networkBacklog >= MainThreadQueueBurstBacklogThreshold) - DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobSyncTrace.LogNetworkDrainBurst(networkBacklog, budget); + // Critical + reliable network protocol work must drain even when the UI queue is busy. + processed += DrainCriticalAndNetworkMainThreadQueues(MainThreadQueueMaxActionsPerPump); - while (processed < budget) + while (_mainThreadQueue.TryDequeue(out var workItem)) { - Action? action = null; - - if (_criticalCoalescedKeys.TryDequeue(out var criticalKey)) + Interlocked.Decrement(ref _mainThreadQueueDepth); + Action? action = workItem.Action; + var actionLabel = workItem.CoalesceKey; + if (actionLabel != null) { - lock (CriticalMainThreadCoalesceSync) + lock (MainThreadCoalesceSync) { - _criticalCoalescedActions.TryGetValue(criticalKey, out action); - _criticalCoalescedActions.Remove(criticalKey); - } - } - else - { - // Three fifths of the regular budget goes to protocol traffic. Direct and - // coalesced work each receive a reserved turn so neither can starve forever. - var phase = processed % 5; - Action? networkAction; - if (phase <= 2 && _networkMainThreadQueue.Reader.TryRead(out networkAction)) - { - action = networkAction; - } - else if (phase == 3 && _mainThreadQueue.TryDequeue(out var directPreferred)) - { - Interlocked.Decrement(ref _mainThreadDirectQueueCount); - action = directPreferred; - } - else if (phase == 4 && _coalescedKeys.TryDequeue(out var preferredKey)) - { - lock (MainThreadCoalesceSync) - { - _coalescedActions.TryGetValue(preferredKey, out action); - _coalescedActions.Remove(preferredKey); - } - } - else if (_networkMainThreadQueue.Reader.TryRead(out networkAction)) - { - action = networkAction; - } - else if (_mainThreadQueue.TryDequeue(out var direct)) - { - Interlocked.Decrement(ref _mainThreadDirectQueueCount); - action = direct; - } - else if (_coalescedKeys.TryDequeue(out var key)) - { - lock (MainThreadCoalesceSync) - { - _coalescedActions.TryGetValue(key, out action); - _coalescedActions.Remove(key); - } - } - else - { - break; + _pendingCoalescedMainThreadKeys.Remove(actionLabel); + _coalescedMainThreadActions.TryGetValue(actionLabel, out action); + _coalescedMainThreadActions.Remove(actionLabel); } } @@ -434,6 +294,7 @@ internal static void ProcessMainThreadQueue() continue; processed++; + var actionStart = perfEnabled ? RuntimeHitchWatch.Start() : 0; try { action(); @@ -442,34 +303,107 @@ internal static void ProcessMainThreadQueue() { _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); } + finally + { + if (perfEnabled) + { + var actionMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionStart); + if (actionMs > maxActionMs) + { + actionLabel ??= DescribeMainThreadAction(action); + maxActionMs = actionMs; + maxActionLabel = actionLabel; + } + + if (actionMs >= RuntimeHitchWatch.MainThreadQueueActionSlowThresholdMs) + { + slowActions++; + actionLabel ??= DescribeMainThreadAction(action); + RuntimeHitchWatch.LogSlow( + _log, + $"GameMenu.MainThreadQueueAction:{actionLabel}", + actionMs, + string.Create( + CultureInfo.InvariantCulture, + $"action={actionLabel} processed={processed} startDepth={startDepth}")); + } + } + } + + if (processed >= MainThreadQueueMaxActionsPerPump) + break; + if (RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart) >= MainThreadQueueBudgetMs) + break; + } + var actionsMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart); + + var remainingDepth = Volatile.Read(ref _mainThreadQueueDepth); + var observedDepth = System.Math.Max(startDepth, remainingDepth); + if (perfEnabled && observedDepth >= RuntimeHitchWatch.MainThreadQueueDepthThreshold) + { + RuntimeHitchWatch.LogCount( + _log, + "GameMenu.MainThreadQueueDepth", + observedDepth, + RuntimeHitchWatch.MainThreadQueueDepthThreshold, + string.Create(CultureInfo.InvariantCulture, $"processed={processed} remaining={remainingDepth}")); } - } - public static void MarkInRun() - { - lock (Sync) + if (actionsMs >= RuntimeHitchWatch.MainThreadQueueActionsSlowThresholdMs) { - _inActualRun = true; + RuntimeHitchWatch.LogSlow( + _log, + "GameMenu.ExecuteMainThreadActions", + actionsMs, + string.Create( + CultureInfo.InvariantCulture, + $"processed={processed} slowActions={slowActions} maxAction={maxActionLabel} maxMs={maxActionMs:0.00} remaining={remainingDepth}")); + } + + var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); + if (hitchMs >= RuntimeHitchWatch.MainThreadQueueSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + _log, + "GameMenu.ProcessMainThreadQueue", + hitchMs, + string.Create(CultureInfo.InvariantCulture, $"processed={processed} startDepth={startDepth} remaining={remainingDepth}")); } - // The (re)started run's hero is up — the restart completed, so stop suppressing level reloads. - ClearClientRestartPending(); - SendRunReadyFromHero(); } - internal static void MarkClientRestartPending() + private static string DescribeMainThreadAction(Action? action) { - Volatile.Write(ref _clientRestartPendingUntilTicks, Environment.TickCount64 + ClientRestartPendingTtlMs); + if (action == null) + return "null"; + + var method = action.Method; + return _mainThreadActionLabelCache.GetOrAdd(method, static m => + { + var declaringType = m.DeclaringType?.FullName; + if (!string.IsNullOrWhiteSpace(declaringType)) + return $"{declaringType}.{m.Name}"; + + return m.Name; + }); } - internal static void ClearClientRestartPending() + public static void MarkInRun() { - Volatile.Write(ref _clientRestartPendingUntilTicks, 0); + lock (Sync) + { + _inActualRun = true; + _continueLaunchInProgress = false; + _continueLaunchStartedAt = DateTime.MinValue; + } + ClearClientRestartPending(); } - internal static bool IsClientRestartPending() + internal static bool IsClientInActualRun() { - var until = Volatile.Read(ref _clientRestartPendingUntilTicks); - return until != 0 && Environment.TickCount64 < until; + lock (Sync) + { + return _role == NetRole.Client && _inActualRun; + } } public static void SetRole(NetRole role) @@ -484,6 +418,7 @@ public static void SetRole(NetRole role) RunLaunchCoordinator.OnRoleChanged(previous, role); if (previous == NetRole.Client && role != NetRole.Client) { + GameDataSync.SwapToLocalSerializerSync(); EnqueueCriticalMainThreadCoalesced("game:restore-original-user", () => { try @@ -506,264 +441,12 @@ public static int ForceGenerateServerSeed(string reason) { _serverSeed = seed; } + if (_role == NetRole.Host) + MUser.UpdateCoopRunSeed(seed, _playerId); _log?.Information("[NetMod] Generated host seed {Seed} ({Reason})", seed, reason); return seed; } - /// - /// Commits one host launch to the wire. The monotonic sequence prevents a client entering - /// Boss Rush (or another nested launch mode) from accidentally reusing the previous run's - /// cached seed while the new SEED packet is still in flight. - /// - public static int RegisterHostRunSeed(int seed, string launchKind, string reason) - { - int sequence; - lock (Sync) - { - _serverSeed = seed; - sequence = _serverSeedSequence == int.MaxValue ? 1 : _serverSeedSequence + 1; - _serverSeedSequence = sequence; - } - - _log?.Information( - "[NetMod] Registered host run seed seq={Sequence} seed={Seed} launch={LaunchKind} ({Reason})", - sequence, - seed, - launchKind ?? string.Empty, - reason); - return sequence; - } - - internal static bool PrecommitInitialHostRunSeed(out int seed, out int sequence, out RunLaunchDescriptor? descriptor) - { - seed = 0; - sequence = 0; - descriptor = null; - - var net = NetRef; - if (net == null || !net.IsAlive || !net.IsHost) - return false; - - const string launchKind = "dc.LaunchMode+NewGame"; - - seed = ForceGenerateServerSeed("title.startNewGame_precommit"); - sequence = RegisterHostRunSeed(seed, launchKind, "title.startNewGame_precommit"); - - lock (Sync) - { - _precommittedHostSeed = seed; - _precommittedHostSeedSequence = sequence; - _precommittedHostLaunchKind = launchKind; - _precommittedHostSeedExpiresAtTicks = Environment.TickCount64 + PrecommittedHostSeedTtlMs; - } - - descriptor = BuildHostRunLaunchDescriptor(seed, sequence, launchKind); - net.SendRunLaunchCommit(descriptor, flush: true); - - // The legacy seed packet remains during protocol migration, but clients no longer - // execute from it until the matching structured RUNEXEC has arrived. - // Send it before the host enters any - // first-run cinematic; User.newGame will reuse and resend this same sequence later. - net.SendSeed(sequence, seed, launchKind); - // The normal send is cached for late joiners. The bounded flush makes sure the - // connected client receives the launch packet before the title screen changes state. - net.SendControlAndFlush($"SEED|{sequence}|{seed}|{launchKind}", 500); - _log?.Information( - "[NetMod] Precommitted initial host run seq={Sequence} seed={Seed} launch={LaunchKind}", - sequence, - seed, - launchKind); - return true; - } - - /// - /// Stages the Boss Rush seed before either game enters the native Boss Rush loader. The - /// BossRushDoor transition is already coordinated by LevelExitSync, so sending the - /// structured commit/execute before the door-ready state guarantees the client has the - /// authoritative seed waiting when its own User.newGame hook runs. - /// - internal static bool PrecommitHostBossRushRunSeed( - string bossRushType, - int doorCx, - int doorCy, - out int seed, - out int sequence) - { - const string launchKind = "dc.LaunchMode+BossRush"; - seed = 0; - sequence = 0; - - var net = NetRef; - if (net == null || !net.IsAlive || !net.IsHost) - return false; - - lock (Sync) - { - var expired = _precommittedHostSeedExpiresAtTicks != 0 && - Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; - if (expired) - ClearPrecommittedHostRunSeedLocked(); - - if (_precommittedHostSeed.HasValue && - _precommittedHostSeedSequence > 0 && - GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind)) - { - seed = _precommittedHostSeed.Value; - sequence = _precommittedHostSeedSequence; - } - } - - if (sequence <= 0) - { - seed = ForceGenerateServerSeed("bossrush_door_precommit"); - sequence = RegisterHostRunSeed(seed, launchKind, "bossrush_door_precommit"); - - lock (Sync) - { - _precommittedHostSeed = seed; - _precommittedHostSeedSequence = sequence; - _precommittedHostLaunchKind = launchKind; - _precommittedHostSeedExpiresAtTicks = Environment.TickCount64 + PrecommittedHostSeedTtlMs; - } - } - - try - { - // Commit and execute before the synchronized door-ready packet. Steam/TCP preserve - // ordering, so the client receives this launch before it is told to activate the - // matching local BossRushDoor. The native Boss Rush variant read from the door - // (bossRushType) is carried so the client can validate it against its own door. - CommitHostRunLaunchFromHook(seed, sequence, launchKind, bossRushType); - net.SendSeed(sequence, seed, launchKind); - net.SendControlAndFlush($"SEED|{sequence}|{seed}|{launchKind}", 500); - _log?.Information( - "[NetMod][BossRushSeed] Precommitted seq={Sequence} seed={Seed} type={BossRushType} door={DoorCx}:{DoorCy}", - sequence, - seed, - string.IsNullOrWhiteSpace(bossRushType) ? "unknown" : bossRushType, - doorCx, - doorCy); - return true; - } - catch (Exception ex) - { - _log?.Warning( - "[NetMod][BossRushSeed] Failed to precommit Boss Rush seed at door={DoorCx}:{DoorCy}: {Message}", - doorCx, - doorCy, - ex.Message); - return false; - } - } - - internal static bool HasPrecommittedHostBossRushLaunch() - { - lock (Sync) - { - var expired = _precommittedHostSeedExpiresAtTicks != 0 && - Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; - if (expired) - ClearPrecommittedHostRunSeedLocked(); - - return _precommittedHostSeed.HasValue && - _precommittedHostSeedSequence > 0 && - GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind); - } - } - - internal static bool HasPendingRemoteBossRushLaunch() - { - var descriptor = RunLaunchCoordinator.GetCurrentRemoteDescriptor(); - if (descriptor == null || !descriptor.BossRush) - return false; - - lock (Sync) - { - return descriptor.Sequence > _consumedRemoteSeedSequence && - _remoteSeedSequence == descriptor.Sequence && - RunLaunchCoordinator.HasExecutableRemoteLaunch(descriptor.Sequence); - } - } - - internal static bool TryGetPendingRemoteBossRushSeed(out int seed) - { - lock (Sync) - { - if (_remoteSeed.HasValue && - _remoteSeedSequence > _consumedRemoteSeedSequence && - GameDataSync.IsBossRushLaunchKind(_remoteLaunchKind)) - { - seed = _remoteSeed.Value; - return true; - } - } - - seed = 0; - return false; - } - - internal static bool TryConsumePrecommittedHostRunSeed( - string launchKind, - out int seed, - out int sequence) - { - lock (Sync) - { - var expired = _precommittedHostSeedExpiresAtTicks != 0 && - Environment.TickCount64 >= _precommittedHostSeedExpiresAtTicks; - if (expired) - ClearPrecommittedHostRunSeedLocked(); - - if (!_precommittedHostSeed.HasValue || _precommittedHostSeedSequence <= 0) - { - seed = 0; - sequence = 0; - return false; - } - - var requestedNewGame = !string.IsNullOrWhiteSpace(launchKind) && - launchKind.Contains("NewGame", StringComparison.OrdinalIgnoreCase); - var stagedNewGame = !string.IsNullOrWhiteSpace(_precommittedHostLaunchKind) && - _precommittedHostLaunchKind.Contains("NewGame", StringComparison.OrdinalIgnoreCase); - var requestedBossRush = GameDataSync.IsBossRushLaunchKind(launchKind); - var stagedBossRush = GameDataSync.IsBossRushLaunchKind(_precommittedHostLaunchKind); - if (!string.Equals(launchKind, _precommittedHostLaunchKind, StringComparison.Ordinal) && - !(requestedNewGame && stagedNewGame) && - !(requestedBossRush && stagedBossRush)) - { - seed = 0; - sequence = 0; - return false; - } - - seed = _precommittedHostSeed.Value; - sequence = _precommittedHostSeedSequence; - ClearPrecommittedHostRunSeedLocked(); - return true; - } - } - - internal static void CancelPrecommittedHostRunSeed(string reason = "precommitted_launch_cancelled") - { - int sequence; - lock (Sync) - { - sequence = _precommittedHostSeedSequence; - ClearPrecommittedHostRunSeedLocked(); - } - - if (sequence > 0) - CancelHostStructuredLaunch(sequence, reason); - } - - private static void ClearPrecommittedHostRunSeedLocked() - { - _precommittedHostSeed = null; - _precommittedHostSeedSequence = 0; - _precommittedHostLaunchKind = string.Empty; - _precommittedHostSeedExpiresAtTicks = 0; - } - public static bool TryGetHostRunSeed(out int seed) { lock (Sync) @@ -779,140 +462,75 @@ public static bool TryGetHostRunSeed(out int seed) return false; } - public static bool TryGetKnownSeed(out int seed) + public static void ReceiveHostRunSeed(int seed) { + int? previousSeed = null; lock (Sync) { - if (_serverSeed.HasValue) - { - seed = _serverSeed.Value; - return true; - } - if (_remoteSeed.HasValue) - { - seed = _remoteSeed.Value; - return true; - } - } - - seed = 0; - return false; - } - - public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) - { - var scheduleInRunReconcile = false; - lock (Sync) - { - if (sequence <= 0) - return; - - if (sequence < _remoteSeedSequence) - return; - - if (sequence == _remoteSeedSequence) - { - if (_remoteSeed == seed) - Monitor.PulseAll(Sync); - return; - } - + previousSeed = _remoteSeed; _remoteSeed = seed; - _remoteSeedSequence = sequence; - _remoteLaunchKind = launchKind ?? string.Empty; if (_role == NetRole.Client) { - // A Boss Rush seed must only be consumed by the client's own Boss Rush launch - // hook. Force-restarting the run on it (the reconcile path) was the historical - // double-load race, and auto-starting a fresh full run from it would launch - // the wrong mode entirely. - var isBossRushSeed = GameDataSync.IsBossRushLaunchKind(launchKind); - if (_inActualRun) + var firstSeedForClient = !previousSeed.HasValue; + var seedChanged = previousSeed.HasValue && previousSeed.Value != seed; + if (_pendingClientRestartSeed.HasValue) + { + _pendingClientRestartSeed = seed; + _pendingClientRestartReason = "host_restart"; + _pendingAutoStart = false; + _autoStartTriggered = false; + } + else if (_inActualRun) { - // A nested launch hook (Boss Rush, challenge, daily, etc.) consumes this - // sequence directly. If no hook consumes it within a short grace window, - // treat it as a host restart/late-join recovery and rebuild from the seed. - scheduleInRunReconcile = sequence > _consumedRemoteSeedSequence && !isBossRushSeed; + if (firstSeedForClient || seedChanged) + { + _inActualRun = false; + _pendingAutoStart = false; + _autoStartTriggered = false; + _pendingClientRestartSeed = seed; + _pendingClientRestartReason = "host_restart"; + } } else { _seedArrived = true; - if (!isBossRushSeed && CanAutoStartStructuredClientLaunchLocked()) - _pendingAutoStart = true; + _pendingAutoStart = true; } } - Monitor.PulseAll(Sync); } - _log?.Information( - "[NetMod] Client received host run seed seq={Sequence} seed={Seed} launch={LaunchKind}", - sequence, - seed, - launchKind ?? string.Empty); - - if (scheduleInRunReconcile) - ScheduleClientRunSeedReconcile(sequence, seed); + _log?.Information("[NetMod] Client received host seed {Seed}", seed); } - private static void ScheduleClientRunSeedReconcile(int sequence, int seed) + public static void ReceiveHostRunRestart(int seed) { - _ = Task.Run(async () => + lock (Sync) { - await Task.Delay(RunSeedTransitionGraceMs).ConfigureAwait(false); - EnqueueCriticalMainThreadCoalesced("game:run-seed-reconcile", () => + _remoteSeed = seed; + _seedArrived = true; + if (_role == NetRole.Client) { - var shouldRestart = false; - lock (Sync) + _autoStartTriggered = false; + if (_pendingClientRestartSeed.HasValue) { - if (_role == NetRole.Client && - _inActualRun && - _remoteSeedSequence == sequence && - _consumedRemoteSeedSequence < sequence) - { - _inActualRun = false; - _pendingAutoStart = false; - _autoStartTriggered = false; - shouldRestart = true; - } + _pendingClientRestartSeed = seed; + _pendingClientRestartReason = "host_same_run_restart"; + _pendingAutoStart = false; + } + else if (_inActualRun) + { + _inActualRun = false; + _pendingAutoStart = false; + _pendingClientRestartSeed = seed; + _pendingClientRestartReason = "host_same_run_restart"; + } + else + { + _pendingAutoStart = true; } - - if (shouldRestart) - QueueClientRestartFromHostSeed(seed, $"unconsumed_host_launch_seq_{sequence}"); - }); - }); - } - - /// - /// Waits for and consumes exactly one not-yet-used host launch seed. Network receive runs on - /// a background thread, so the game launch hook can safely form a short deterministic barrier. - /// - public static bool TryConsumeNextRemoteRunSeed(out int seed, out int sequence, out string launchKind) - { - if (RunLaunchCoordinator.TryConsumeRemoteLaunch( - RemoteRunSeedWaitMs, - out var descriptor, - out var error) && - descriptor != null) - { - seed = descriptor.RunSeed; - sequence = descriptor.Sequence; - launchKind = descriptor.LaunchKind; - lock (Sync) - { - _remoteSeed = seed; - _remoteSeedSequence = sequence; - _remoteLaunchKind = launchKind; - _consumedRemoteSeedSequence = sequence; - _seedArrived = true; - Monitor.PulseAll(Sync); } - return true; } - _log?.Error("[NetMod][RunLaunch] {Error}", error); - seed = 0; - sequence = 0; - launchKind = string.Empty; - return false; + _log?.Information("[NetMod] Client received same-run restart {Seed}", seed); } internal static void QueueHostRestartFromDeath(string reason) @@ -939,30 +557,82 @@ internal static void QueueHostRestartFromDeath(string reason) } _log?.Information("[NetMod] Host restarting run ({Reason})", reason); + GameDataSync.ClearPendingBossRuneReloadState(); + GameDataSync.SendBossRune(game.user, NetRef); + GameDataSync.BeginSameRunRestart(GameDataSync.Seed); + try { NetRef?.SendRunRestart(GameDataSync.Seed); } catch { } + var restartLaunch = GameDataSync.BuildSameRunRestartLaunchMode(); + var restartIsCustom = GameDataSync.ResolveCurrentRunIsCustom(); + var restartStreamEnabled = GameDataSync.ResolveCurrentRunStreamEnabled(); try { - var main = dc.Main.Class.ME; - if (main != null) - { - main.launchGame(GameDataSync._launch, null, 0.8); - return; - } + RestartCurrentWorldDirect(game, GameDataSync.Seed, restartStreamEnabled, restartIsCustom, restartLaunch); } catch (Exception ex) { - _log?.Warning("[NetMod] Host launchGame restart failed, fallback to direct newGame: {Message}", ex.Message); + _log?.Warning("[NetMod] Host direct restart failed: {Message}", ex.Message); } - - game.destroy(); - game.disposeImmediately(); - game.user.newGame(GameDataSync.Seed, GameDataSync._isTwitch, GameDataSync._isCustom, GameDataSync._mode, GameDataSync._launch); }); } + private static void RestartCurrentWorldDirect( + dc.pr.Game game, + int seed, + bool streamEnabled, + bool customMode, + dc.LaunchMode launchMode) + { + var user = game.user; + if (user == null) + return; + + PrepareCurrentWorldForRestartTransition(game); + try { game.destroy(); } catch { } + try { game.disposeImmediately(); } catch { } + user.newGame(seed, GameDataSync._isTwitch, streamEnabled, customMode, launchMode); + } + + private static void RestartCurrentWorldWithLoading(dc.pr.Game game, dc.LaunchMode launchMode) + { + var main = dc.Main.Class.ME; + if (main == null) + throw new InvalidOperationException("Main is unavailable for restart launch."); + + PrepareCurrentWorldForRestartTransition(game); + main.launchGame(launchMode, null, null); + } + + private static void PrepareCurrentWorldForRestartTransition(dc.pr.Game game) + { + try { ModEntry.Instance?.DisposeCoopGhostRuntimeForWorldTeardown(game); } catch { } + + try + { + var cine = game.curCine; + if (cine != null) + { + try { cine.destroyed = true; } catch { } + try { cine.disposeImmediately(); } catch { } + if (ReferenceEquals(game.curCine, cine)) + game.curCine = null; + } + } + catch + { + } + + try + { + if (game.controller != null) + game.controller.manualLock = false; + } + catch + { + } + } + private static void QueueClientRestartFromHostSeed(int seed, string reason) { - // Set synchronously (before the queued action runs) so any level graph that arrives in the - // meantime is prevented from firing an in-place reload that would pre-empt this full restart. MarkClientRestartPending(); EnqueueCriticalMainThreadCoalesced("game:client-restart", () => { @@ -982,24 +652,53 @@ private static void QueueClientRestartFromHostSeed(int seed, string reason) } _log?.Information("[NetMod] Client restarting run from host seed {Seed} ({Reason})", seed, reason); + GameDataSync.ClearPendingBossRuneReloadState(); + GameDataSync.RestoreRemoteUserData(game.user); + GameDataSync.BeginSameRunRestart(seed); + var restartLaunch = GameDataSync.BuildSameRunRestartLaunchMode(); try { - var main = dc.Main.Class.ME; - if (main != null) - { - main.launchGame(GameDataSync._launch, null, 0.8); - return; - } + RestartCurrentWorldWithLoading(game, restartLaunch); } catch (Exception ex) { - _log?.Warning("[NetMod] Client launchGame restart failed, fallback to direct newGame: {Message}", ex.Message); + GameDataSync.CancelSameRunRestart(); + _log?.Warning("[NetMod] Client loading restart failed: {Message}", ex.Message); } + }); + } + + private static void TryProcessPendingClientRestart() + { + int seed; + string reason; + lock (Sync) + { + if (_role != NetRole.Client || !_pendingClientRestartSeed.HasValue) + return; - game.destroy(); - game.disposeImmediately(); - game.user.newGame(seed, GameDataSync._isTwitch, GameDataSync._isCustom, GameDataSync._mode, GameDataSync._launch); - }); + if (!IsRemoteRunSyncReadyForLaunchLocked()) + return; + + seed = _pendingClientRestartSeed.Value; + reason = string.IsNullOrWhiteSpace(_pendingClientRestartReason) + ? "host_restart" + : _pendingClientRestartReason; + _pendingClientRestartSeed = null; + _pendingClientRestartReason = string.Empty; + _pendingAutoStart = false; + _autoStartTriggered = false; + } + + QueueClientRestartFromHostSeed(seed, reason); + } + + internal static bool HasPendingClientRestart() + { + lock (Sync) + { + return _role == NetRole.Client && _pendingClientRestartSeed.HasValue; + } } public static bool TryGetRemoteSeed(out int seed) @@ -1041,17 +740,22 @@ public static void ReceiveRemoteUsername(string username) lock (Sync) { previous = _remoteUsername; + if (string.Equals(previous, cleaned, StringComparison.Ordinal)) + return; + _remoteUsername = cleaned; } - var changed = !string.Equals(previous, cleaned, StringComparison.Ordinal); - if (changed) - _log?.Information("[NetMod] Received remote username {Username}", cleaned); - if (_role == NetRole.Host && changed) + + // Lobby heartbeats re-send the same name ~2/sec; only react on real changes. + _log?.Information("[NetMod] Received remote username {Username}", cleaned); + if (_role == NetRole.Host) { var userForMsg = cleaned; EnqueueMainThread(() => MultiplayerUI.PushSystemMessage(FormatLocalized("{0} connected to the server.", userForMsg))); } + + RequestLobbyMenuRefresh(); } private static void SendCachedGeneratePayload() @@ -1065,15 +769,8 @@ private static void SendCachedGeneratePayload() levelDesc = _cachedLevelDescSync; } - if (levelDesc == null) - return; - - var payload = new - { - levelDesc = levelDesc ?? new LevelDescSync(), - rawDesc = string.Empty - }; - var json = JsonConvert.SerializeObject(payload); + var json = BuildGeneratePayloadJson(levelDesc); + SendCoopStateToRemote(); net.SendGeneratePayload(json); } @@ -1096,24 +793,23 @@ private static void CacheLevelDescSync(LevelDescSync? sync) public static void TickMenu(double dt) { UpdateHostDisconnectCountdown(); + TryProcessPendingClientRestart(); if (DateTime.UtcNow < _autoStartRetryAt) return; bool shouldStart = false; - int autoStartQueuedSequence = 0; lock (Sync) { if (_role == NetRole.Client && !_inActualRun && + !_pendingClientRestartSeed.HasValue && _pendingAutoStart && - _seedArrived && - CanAutoStartStructuredClientLaunchLocked() && + IsPendingLaunchReadyForAutoStartLocked() && !_autoStartTriggered) { _autoStartTriggered = true; shouldStart = true; - autoStartQueuedSequence = _structuredLaunchExecuteSequence; } } @@ -1150,7 +846,7 @@ public static void TickMenu(double dt) return; } - ts.startNewGame(custom: true); + TryAutoStartPendingLaunch(ts); } finally { @@ -1159,9 +855,6 @@ public static void TickMenu(double dt) mutex?.Dispose(); } _log?.Information("[NetMod] Auto-started new game after seed"); - // Protocol 17 correction: only now that the client has actually invoked the native - // new game do we confirm RUNQUEUED to the host (which is held until this arrives). - NotifyClientLaunchQueued(autoStartQueuedSequence); } catch (IOException ioEx) { @@ -1207,224 +900,241 @@ private static void NotifyLevelDescReceived() private static void ShowMultiplayerMenu(TitleScreen screen) { - _roomStatusMenuKind = 0; - screen.clearMenu(); - AddInfoLine(screen, GetText.Instance.GetString("Co-op"), 0xFFE48A); - AddMenuButton(screen, GetText.Instance.GetString("Host room"), () => ShowHostTransportMenu(screen), GetText.Instance.GetString("Create a Steam or IP/VPN room")); - AddMenuButton(screen, GetText.Instance.GetString("Join room"), () => ShowJoinTransportMenu(screen), GetText.Instance.GetString("Join with Steam invite/lobby code or IP")); - AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => screen.mainMenu(), GetText.Instance.GetString("Return to main menu")); + + + + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + AddMenuButton( + screen, + GetText.Instance.GetString("Host game"), + () => ShowHostTransportMenu(screen), + GetText.Instance.GetString("Create a multiplayer session")); + AddMenuButton( + screen, + 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")); + 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; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open multiplayer menu: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } } private static void ShowHostTransportMenu(TitleScreen screen) { - _roomStatusMenuKind = 0; - screen.clearMenu(); - AddInfoLine(screen, GetText.Instance.GetString("Host room"), 0xFFE48A); - AddMenuButton(screen, GetText.Instance.GetString("Steam friends lobby"), () => NativeStartSteamHost(screen), GetText.Instance.GetString("Create Steam lobby and invite friends")); - AddMenuButton(screen, GetText.Instance.GetString("IP / VPN lobby"), () => ShowLanConnectionMenu(screen, NetRole.Host), GetText.Instance.GetString("Hamachi, Radmin, ZeroTier, LAN or port forward")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => ShowMultiplayerMenu(screen), GetText.Instance.GetString("Back to multiplayer menu")); + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + AddMenuButton( + screen, + GetText.Instance.GetString("Lan host"), + () => ShowConnectionMenu(screen, NetRole.Host), + GetText.Instance.GetString("Use direct IP/port hosting")); + + AddMenuButton( + screen, + GetText.Instance.GetString("Steam host"), + () => StartSteamHost(screen), + GetText.Instance.GetString("Create Steam lobby and start immediately")); + + AddMenuButton( + screen, + 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")); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open host transport menu: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } } private static void ShowJoinTransportMenu(TitleScreen screen) { - _roomStatusMenuKind = 0; - screen.clearMenu(); - AddInfoLine(screen, GetText.Instance.GetString("Join room"), 0xFFE48A); - AddMenuButton(screen, GetText.Instance.GetString("Join Steam invite/code"), () => NativeStartSteamJoin(screen), GetText.Instance.GetString("Use lobby code from clipboard or accepted Steam invite")); - AddMenuButton(screen, GetText.Instance.GetString("Join IP / VPN"), () => ShowLanConnectionMenu(screen, NetRole.Client), GetText.Instance.GetString("Connect by Hamachi/Radmin/ZeroTier/IP")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => ShowMultiplayerMenu(screen), GetText.Instance.GetString("Back to multiplayer menu")); + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + AddMenuButton( + screen, + GetText.Instance.GetString("Lan join"), + () => ShowConnectionMenu(screen, NetRole.Client), + GetText.Instance.GetString("Connect by IP/port")); + + AddMenuButton( + screen, + GetText.Instance.GetString("Steam join"), + () => StartSteamJoin(screen), + GetText.Instance.GetString("Connect by Steam lobby id/code from clipboard")); + + AddMenuButton( + screen, + 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 join"), + GetText.Instance.GetString("Steam join"), + GetText.Instance.GetString("Back")); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open join transport menu: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } } - private static void ShowLanConnectionMenu(TitleScreen screen, NetRole role) + private static void ShowConnectionMenu(TitleScreen screen, NetRole role) { - _roomStatusMenuKind = 0; _menuSelection = role; _menuTransport = ConnectionTransport.Lan; if (role == NetRole.Client) _waitingForHost = true; - screen.clearMenu(); + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); - AddMenuButton(screen, $"{GetText.Instance.GetString("Username: ")}{_username}", () => - OpenTextInput(screen, GetText.Instance.GetString("Username"), _username, value => - { - _username = CleanUsername(value); - SaveConfig(); - SendUsernameToRemote(); - ShowLanConnectionMenu(screen, role); - }, noSpaces: true), GetText.Instance.GetString("Edit display name")); - - AddMenuButton(screen, $"{GetText.Instance.GetString("IP: ")}{_mpIp}", () => - OpenTextInput(screen, GetText.Instance.GetString("IP address"), _mpIp, value => - { - _mpIp = string.IsNullOrWhiteSpace(value) ? "127.0.0.1" : value; - SaveConfig(); - ShowLanConnectionMenu(screen, role); - }, noSpaces: true), GetText.Instance.GetString("Edit IP")); + AddMenuButton( + screen, + $"{GetText.Instance.GetString("Username: ")}{_username}", + () => EditUsername(screen), + GetText.Instance.GetString("Edit display name")); - AddMenuButton(screen, $"{GetText.Instance.GetString("Port: ")}{_mpPort}", () => - OpenTextInput(screen, GetText.Instance.GetString("Port"), _mpPort.ToString(), value => + AddMenuButton(screen, $"{GetText.Instance.GetString("IP: ")}{_mpIp}", () => { - if (!int.TryParse(value, out var parsed) || parsed <= 0 || parsed > 65535) - parsed = 1234; - _mpPort = parsed; - SaveConfig(); - ShowLanConnectionMenu(screen, role); - }, noSpaces: true), GetText.Instance.GetString("Edit port")); + OpenTextInput(screen, GetText.Instance.GetString("IP address"), _mpIp, value => + { + _mpIp = string.IsNullOrWhiteSpace(value) ? "127.0.0.1" : value; + SaveConfig(); + ShowConnectionMenu(screen, role); + }, noSpaces: true); + }, GetText.Instance.GetString("Edit IP")); - var actionLabel = role == NetRole.Host ? GetText.Instance.GetString("Host") : GetText.Instance.GetString("Join"); - AddMenuButton(screen, actionLabel, () => - { + AddMenuButton(screen, $"{GetText.Instance.GetString("Port: ")}{_mpPort}", () => + { + OpenTextInput(screen, GetText.Instance.GetString("Port"), _mpPort.ToString(), value => + { + if (!int.TryParse(value, out var parsed) || parsed <= 0 || parsed > 65535) + parsed = 1234; + _mpPort = parsed; + SaveConfig(); + ShowConnectionMenu(screen, role); + }, noSpaces: true); + }, GetText.Instance.GetString("Edit port")); + + var actionLabel = role == NetRole.Host + ? GetText.Instance.GetString("Host") + : GetText.Instance.GetString("Join"); if (role == NetRole.Host) { - StartHostServerOnly(); - ShowHostStatusMenu(screen); - screen.ShouldAutoHideConnectionUI(true); + AddMenuButton(screen, actionLabel, () => + { + StartHostServerOnly(); + ShowHostStatusMenu(screen); + screen.ShouldAutoHideConnectionUI(true); + }, GetText.Instance.GetString("Start hosting")); } else { - StartNetwork(role, screen); - ShowClientWaitingMenu(screen); - screen.ShouldAutoHideConnectionUI(true); + AddMenuButton(screen, actionLabel, () => + { + StartNetwork(role, screen); + ShowClientWaitingMenu(screen); + screen.ShouldAutoHideConnectionUI(true); + }, GetText.Instance.GetString("Connect to host")); } - }, role == NetRole.Host ? GetText.Instance.GetString("Start hosting") : GetText.Instance.GetString("Connect to host")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => - { - screen.ShouldAutoHideConnectionUI(false); + AddMenuButton( + screen, + GetText.Instance.GetString("Back"), + () => + { + if (role == NetRole.Host) + ShowHostTransportMenu(screen); + else + ShowJoinTransportMenu(screen); + screen.ShouldAutoHideConnectionUI(false); + }, + GetText.Instance.GetString("Back to multiplayer menu")); + 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) - ShowHostTransportMenu(screen); - else - ShowJoinTransportMenu(screen); - }, GetText.Instance.GetString("Back to multiplayer menu")); - - if (role == NetRole.Host) - SetRole(NetRole.None); - } - - private static void ShowHostStatusMenu(TitleScreen screen) - { - _roomStatusMenuKind = 1; - screen.clearMenu(); - AddInfoLine(screen, BuildRoomSummaryLine(), 0xFFE48A); - AddInfoLine(screen, BuildFriendSummaryLine(), NetRef != null && NetRef.HasRemote ? 0xA6FF8A : 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("Start run for everyone"), () => StartHostRun(screen), GetText.Instance.GetString("Launch the synced co-op run")); - AddMenuButton(screen, GetText.Instance.GetString("Refresh room"), () => ShowHostStatusMenu(screen), GetText.Instance.GetString("Refresh lobby status")); - AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); - if (_menuTransport == ConnectionTransport.Steam) - { - AddMenuButton(screen, GetText.Instance.GetString("Invite Steam friends"), () => OpenSteamInviteOverlayFromMenu(screen), GetText.Instance.GetString("Open Steam friend invite overlay")); - AddMenuButton(screen, GetText.Instance.GetString("Copy Steam room code"), () => { TryCopySteamLobbyCodeFromUi(); ShowHostStatusMenu(screen); }, GetText.Instance.GetString("Copy lobby code for friend")); + { + SetRole(NetRole.None); + } } - AddMenuButton(screen, GetText.Instance.GetString("Stop hosting"), () => - { - StopNetworkFromMenu(); - SetRole(NetRole.None); - _menuSelection = NetRole.None; - ShowMultiplayerMenu(screen); - screen.ShouldAutoHideConnectionUI(false); - }, GetText.Instance.GetString("Close room and go back")); - } - - private static void ShowClientWaitingMenu(TitleScreen screen) - { - _roomStatusMenuKind = 2; - screen.clearMenu(); - AddInfoLine(screen, BuildRoomSummaryLine(), 0xFFE48A); - AddInfoLine(screen, BuildFriendSummaryLine(), NetRef != null && NetRef.HasRemote ? 0xA6FF8A : 0xE0E0E0); - AddInfoLine(screen, GetText.Instance.GetString("Waiting for host to start..."), 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("Refresh room"), () => ShowClientWaitingMenu(screen), GetText.Instance.GetString("Refresh lobby status")); - AddMenuButton(screen, GetText.Instance.GetString("Disconnect"), () => - { - StopNetworkFromMenu(); - _waitingForHost = false; - ResetClientConnectState(); - _menuSelection = NetRole.None; - ResetSteamState(); - screen.mainMenu(); - screen.ShouldAutoHideConnectionUI(false); - }, GetText.Instance.GetString("Disconnect and return to main menu")); - AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); - } - - - - public static void RefreshRoomStatusMenuIfVisible() - { - if (_roomStatusMenuKind == 0) - return; - if ((DateTime.UtcNow - _lastRoomStatusAutoRefresh).TotalSeconds < 1.0) - return; - _lastRoomStatusAutoRefresh = DateTime.UtcNow; - - EnqueueMainThreadCoalesced("ui:auto-refresh-room-status", () => + catch (Exception ex) { - var screen = GetTitleScreen(); - if (screen == null) - return; - if (_roomStatusMenuKind == 1) - ShowHostStatusMenu(screen); - else if (_roomStatusMenuKind == 2) - ShowClientWaitingMenu(screen); - }); - } - - - private static void OpenSteamInviteOverlayFromMenu(TitleScreen screen) - { - if (_steamLobbyId == 0UL) + _log?.Warning("[NetMod] Failed to show connection menu: {Message}", ex.Message); + } + finally { - AddInfoLine(screen, GetText.Instance.GetString("No Steam room yet."), 0xFF9090); - return; + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; } - if (!SteamConnect.TryOpenInviteOverlay(_steamLobbyId, out var error)) - _log?.Warning("[NetMod][Steam] Invite overlay failed: {Error}", error); - ShowHostStatusMenu(screen); - } - - private static string BuildRoomSummaryLine() - { - var transport = _menuTransport == ConnectionTransport.Steam ? "Steam" : "IP/VPN"; - var role = _role == NetRole.Host ? "Host" : _role == NetRole.Client ? "Client" : _menuSelection == NetRole.Host ? "Host" : _menuSelection == NetRole.Client ? "Client" : "Room"; - var code = _menuTransport == ConnectionTransport.Steam ? GetSteamLobbyCodeForUi() : $"{_mpIp}:{_mpPort}"; - if (string.IsNullOrWhiteSpace(code)) - code = _menuTransport == ConnectionTransport.Steam ? "creating..." : $"{_mpIp}:{_mpPort}"; - return $"{transport} {role} | {code}"; - } - - private static string BuildFriendSummaryLine() - { - var net = NetRef; - if (net == null || !net.IsAlive) - return "Not connected"; - if (!net.HasRemote) - return net.IsHost ? "Waiting for friend..." : "Connecting to host..."; - var name = string.IsNullOrWhiteSpace(_remoteUsername) || string.Equals(_remoteUsername, "guest", StringComparison.OrdinalIgnoreCase) - ? "friend" - : _remoteUsername.Trim(); - if (net.IsHost) - return $"Same lobby: yes | Friend: {name}"; - return $"Same lobby: yes | Host: {name}"; - } - - private static void ShowConnectionErrorPopup(TitleScreen screen, string title, string details, Action onOk) - { - screen.clearMenu(); - AddInfoLine(screen, title, 0xFF9090); - if (!string.IsNullOrWhiteSpace(details)) - AddInfoLine(screen, details, 0xE0E0E0); - AddMenuButton(screen, GetText.Instance.GetString("OK"), onOk, GetText.Instance.GetString("Return to previous menu")); - } - - private static void AddInfoLine(TitleScreen screen, string text, int? infoColor = null) - { - int colorVal = infoColor ?? 0xFFFFFF; - var cb = new HlAction(() => { }); - screen.addMenu(MakeHLString(text), cb, MakeHLString(string.Empty), false, Ref.From(ref colorVal)); } - private static void SharedStartSteamHost(Action showError, Action showStatus, Action showTransport) + private static void StartSteamHost(TitleScreen screen) { _menuSelection = NetRole.Host; _menuTransport = ConnectionTransport.Steam; @@ -1439,9 +1149,11 @@ private static void SharedStartSteamHost(Action showErro if (NetRef == null || !NetRef.IsAlive || !NetRef.IsHost) { _log?.Warning("[NetMod][Steam] Host start failed: host server was not created"); - showError(GetText.Instance.GetString("Steam host failed"), + ShowConnectionErrorPopup( + screen, + GetText.Instance.GetString("Steam host failed"), GetText.Instance.GetString("Could not start Steam host. Check console logs."), - showTransport); + () => ShowHostTransportMenu(screen)); return; } @@ -1450,9 +1162,11 @@ private static void SharedStartSteamHost(Action showErro { StopNetworkFromMenu(); _log?.Warning("[NetMod][SteamWorkerError] {Error}", lobby?.Error ?? "Lobby creation failed"); - showError(GetText.Instance.GetString("Steam host failed"), + ShowConnectionErrorPopup( + screen, + GetText.Instance.GetString("Steam host failed"), GetText.Instance.GetString("Steam lobby creation failed. Check console logs."), - showTransport); + () => ShowHostTransportMenu(screen)); return; } @@ -1465,109 +1179,35 @@ private static void SharedStartSteamHost(Action showErro ConnectionUI.NotifyConnectionsChanged(); _log?.Information("[NetMod][Steam] Host lobby ready: id={LobbyId} code={LobbyCode}", _steamLobbyId, _steamLobbyCode); + var copied = SteamConnect.TryCopyLobbyCodeToClipboard(_steamLobbyCode) || SteamConnect.TryCopyLobbyIdToClipboard(lobby.LobbyId); if (copied) MultiplayerUI.PushSystemMessage("Lobby id copied to clipboard"); - showStatus(); + ShowHostStatusMenu(screen); + screen.ShouldAutoHideConnectionUI(true); } - private static void NativeStartSteamHost(TitleScreen screen) + private static void StartSteamJoin(TitleScreen screen) { - SharedStartSteamHost( - showError: (title, details, onOk) => ShowConnectionErrorPopup(screen, title, details, onOk), - showStatus: () => { ShowHostStatusMenu(screen); screen.ShouldAutoHideConnectionUI(true); }, - showTransport: () => ShowHostTransportMenu(screen) - ); - } + _menuSelection = NetRole.Client; + _menuTransport = ConnectionTransport.Steam; + _steamLobbyActive = false; + _steamLobbyId = 0; + _steamLobbyCode = string.Empty; + _steamHostSteamId = 0UL; + ApplySteamPersonaUsername(); - private static void NativeStartSteamJoin(TitleScreen screen) - { _steamJoinLobbyResolvePending = true; - var joinGeneration = Interlocked.Increment(ref _steamJoinResolveGeneration); - _waitingForHost = true; - _clientConnecting = true; - ShowClientWaitingMenu(screen); - screen.ShouldAutoHideConnectionUI(true); - ConnectionUI.NotifyConnectionsChanged(); - + PrepareSteamJoinConnectionUiOnly(screen); _ = Task.Run(() => { var ok = SteamConnect.TryResolveJoinEndpointFromClipboard(out var join); - EnqueueMainThreadCoalesced("steam:join-result", () => - { - if (joinGeneration != Volatile.Read(ref _steamJoinResolveGeneration) || !_steamJoinLobbyResolvePending) - return; - ApplySteamJoinResult(screen, ok, join, fromOverlay: false); - }); + EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: false)); }); } - private static void ApplySteamJoinResult(TitleScreen screen, bool ok, SteamConnect.JoinLobbyResult join, bool fromOverlay) - { - SharedApplySteamJoinResult(ok, join, fromOverlay, - showError: (title, details, onBack) => ShowConnectionErrorPopup(screen, title, details, onBack), - showStatus: () => { ShowClientWaitingMenu(screen); screen.ShouldAutoHideConnectionUI(true); }, - showTransport: () => ShowJoinTransportMenu(screen) - ); - } - - private static void SharedApplySteamJoinResult(bool ok, SteamConnect.JoinLobbyResult join, bool fromOverlay, - Action showError, Action showStatus, Action showTransport) - { - _steamJoinLobbyResolvePending = false; - - if (fromOverlay) - _log?.Information("[NetMod][Steam] Overlay join result: ok={Ok} error={Error}", ok, join.Error ?? "(none)"); - - if (!ok) - { - StopNetworkFromMenu(); - _log?.Warning("[NetMod][SteamWorkerError] {Error}", join.Error); - showError(GetText.Instance.GetString("Steam join failed"), - GetText.Instance.GetString("Steam join failed. Check console logs."), - showTransport); - return; - } - - if (!string.IsNullOrWhiteSpace(join.PersonaName)) - ApplySteamPersonaUsername(join.PersonaName); - - if (join.HostSteamId == 0UL && join.Endpoint == null) - { - showError(GetText.Instance.GetString("Steam join failed"), - GetText.Instance.GetString("Steam lobby endpoint is invalid. Check console logs."), - showTransport); - return; - } - - if (join.Endpoint != null) - { - _mpIp = join.Endpoint.Address.ToString(); - _mpPort = join.Endpoint.Port; - SaveConfig(); - } - - _steamLobbyId = join.LobbyId; - _steamLobbyCode = SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); - _steamHostSteamId = join.HostSteamId; - ConnectionUI.NotifyConnectionsChanged(); - _log?.Information("[NetMod][Steam] Joined lobby: id={LobbyId} code={LobbyCode} hostSteamId={HostSteamId}", _steamLobbyId, _steamLobbyCode, _steamHostSteamId); - - var ts = GetTitleScreen(); - if (ts == null) - { - showError(GetText.Instance.GetString("Steam join failed"), - GetText.Instance.GetString("Main menu is not available."), - showTransport); - return; - } - - StartNetwork(NetRole.Client, ts); - showStatus(); - } - internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) { var screen = GetTitleScreen(); @@ -1589,22 +1229,12 @@ internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) ApplySteamPersonaUsername(); _steamJoinLobbyResolvePending = true; - var joinGeneration = Interlocked.Increment(ref _steamJoinResolveGeneration); - _waitingForHost = true; - _clientConnecting = true; - ShowClientWaitingMenu(screen); - screen.ShouldAutoHideConnectionUI(true); - ConnectionUI.NotifyConnectionsChanged(); + PrepareSteamJoinConnectionUiOnly(screen); _ = Task.Run(() => { _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId})", lobbyId); var ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out var join); - EnqueueMainThreadCoalesced("steam:join-result", () => - { - if (joinGeneration != Volatile.Read(ref _steamJoinResolveGeneration) || !_steamJoinLobbyResolvePending) - return; - ApplySteamJoinResult(screen, ok, join, fromOverlay: true); - }); + EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: true)); }); } @@ -1623,29 +1253,120 @@ private static void ApplySteamPersonaUsername(string? preferredPersona = null) SendUsernameToRemote(); } - private static bool _steamUnavailableNotified; + /// Clears title menu and shows ConnectionUI while the Steam lobby is resolved off-thread. + private static void PrepareSteamJoinConnectionUiOnly(TitleScreen screen) + { + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + RemoveMenuItems(screen, "About Core Modding", GetText.Instance.GetString("Play multiplayer")); + _inClientWaitingMenu = false; + _inHostStatusMenu = false; + screen.ShouldAutoHideConnectionUI(true); + ConnectionUI.NotifyConnectionsChanged(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to prepare Steam join UI: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } + } - /// - /// True only when the Steam transport is both selected AND usable. Without a working - /// Steam client the lobby path can never connect, so the menu quietly falls back to the - /// direct IP/LAN transport instead of failing with an obscure lobby error. - /// - private static bool ShouldUseSteamTransport() + private static void ApplySteamJoinResult(TitleScreen screen, bool ok, SteamConnect.JoinLobbyResult join, bool fromOverlay) { - if (_menuTransport != ConnectionTransport.Steam) - return false; + _steamJoinLobbyResolvePending = false; - if (ModEntry.IsSteamAvailable) - return true; + if (fromOverlay) + _log?.Information("[NetMod][Steam] Overlay join result: ok={Ok} error={Error}", ok, join.Error ?? "(none)"); - if (!_steamUnavailableNotified) + if (!ok) { - _steamUnavailableNotified = true; - _log?.Warning("[NetMod] Steam transport unavailable; using direct IP/LAN transport instead"); - MultiplayerUI.PushSystemMessage(Localize("Steam unavailable - using direct IP/LAN instead.")); + _log?.Warning("[NetMod][SteamWorkerError] {Error}", join.Error); + ShowConnectionErrorPopup( + screen, + GetText.Instance.GetString("Steam join failed"), + GetText.Instance.GetString("Steam join failed. Check console logs."), + () => ShowJoinTransportMenu(screen)); + return; } - return false; + if (!string.IsNullOrWhiteSpace(join.PersonaName)) + ApplySteamPersonaUsername(join.PersonaName); + + if (join.HostSteamId == 0UL && join.Endpoint == null) + { + _log?.Warning("[NetMod][Steam] Join failed: lobby endpoint and host Steam id are missing"); + ShowConnectionErrorPopup( + screen, + GetText.Instance.GetString("Steam join failed"), + GetText.Instance.GetString("Steam lobby endpoint is invalid. Check console logs."), + () => ShowJoinTransportMenu(screen)); + return; + } + + if (join.Endpoint != null) + { + _mpIp = join.Endpoint.Address.ToString(); + _mpPort = join.Endpoint.Port; + SaveConfig(); + } + else if (join.HostSteamId != 0UL) + { + _log?.Information("[NetMod][Steam] {Source} join: P2P-only (hostSteamId={HostSteamId})", fromOverlay ? "Overlay" : "Clipboard", join.HostSteamId); + } + _steamLobbyId = join.LobbyId; + _steamLobbyCode = SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); + _steamHostSteamId = join.HostSteamId; + ConnectionUI.NotifyConnectionsChanged(); + _log?.Information("[NetMod][Steam] Joined lobby: id={LobbyId} code={LobbyCode} hostSteamId={HostSteamId}", _steamLobbyId, _steamLobbyCode, _steamHostSteamId); + + StartNetwork(NetRole.Client, screen); + ShowClientWaitingMenu(screen); + screen.ShouldAutoHideConnectionUI(true); + } + + private static void ShowConnectionErrorPopup(TitleScreen screen, string title, string details, Action onOk) + { + var prevSuppress = _suppressAutoButton; + _suppressAutoButton = true; + var prevIsMain = GetIsMainMenu(screen); + try + { + SetIsMainMenu(screen, false); + screen.clearMenu(); + + AddInfoLine(screen, title, infoColor: 0xFF9090); + if (!string.IsNullOrWhiteSpace(details)) + AddInfoLine(screen, details, infoColor: 0xE0E0E0); + + AddMenuButton( + screen, + 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; + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to open connection error popup: {Message}", ex.Message); + } + finally + { + SetIsMainMenu(screen, prevIsMain); + _suppressAutoButton = prevSuppress; + } } private static void StartNetwork(NetRole role, TitleScreen screen) @@ -1660,43 +1381,42 @@ private static void StartNetwork(NetRole role, TitleScreen screen) if (role == NetRole.Host) { - if (ShouldUseSteamTransport()) + PrepareLobbyForNewNetworkSession(clearRemoteCoopState: true); + var streamEnabled = TryGetStreamEnabled(screen); + if (_menuTransport == ConnectionTransport.Steam) ModEntry.Instance.StartSteamHostFromMenu(_mpPort); else ModEntry.Instance.StartHostFromMenu(_mpIp, _mpPort); _waitingForHost = false; - StartHostRun(screen); + SetAuthoritativePendingNewGameLaunch(custom: false, streamEnabled); + RememberPendingLaunch(PendingLaunchAction.NewGame, custom: false, streamEnabled, sendToRemote: true); + TryLaunchNewGame(screen, custom: false, streamEnabled); } else if (role == NetRole.Client) { - if (ShouldUseSteamTransport()) + PrepareLobbyForNewNetworkSession(clearRemoteCoopState: true); + if (_menuTransport == ConnectionTransport.Steam) { if (_steamHostSteamId == 0UL) { _log?.Warning("[NetMod][Steam] Client start aborted: host Steam id is missing"); - var ts = GetTitleScreen(); - if (ts != null) - ShowConnectionErrorPopup(ts, - GetText.Instance.GetString("Steam join failed"), - GetText.Instance.GetString("Steam host id is missing. Check console logs."), - () => ShowJoinTransportMenu(ts)); + ShowConnectionErrorPopup( + screen, + GetText.Instance.GetString("Steam join failed"), + GetText.Instance.GetString("Steam host id is missing. Check console logs."), + () => ShowJoinTransportMenu(screen)); return; } } lock (Sync) { - _levelDescArrived = false; - _pendingAutoStart = false; - _autoStartTriggered = false; - _seedArrived = false; - ClearStructuredLaunchFlagsLocked(); _clientConnectAttempt = 0; _clientConnecting = true; _waitingForHost = true; } - if (ShouldUseSteamTransport()) + if (_menuTransport == ConnectionTransport.Steam) ModEntry.Instance.StartSteamClientFromMenu(_steamHostSteamId); else ModEntry.Instance.StartClientFromMenu(_mpIp, _mpPort); @@ -1720,11 +1440,13 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) if (NetRef != null && NetRef.IsAlive && NetRef.IsHost) { + PrepareLobbyForNewNetworkSession(); _waitingForHost = false; return; } - if (ShouldUseSteamTransport()) + PrepareLobbyForNewNetworkSession(clearRemoteCoopState: true); + if (_menuTransport == ConnectionTransport.Steam) { ModEntry.Instance.StartSteamHostFromMenu(_mpPort); } @@ -1744,37 +1466,30 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) private static void StartHostRun(TitleScreen screen) { - lock (Sync) - { - if (_initialHostLaunchPendingSequence > 0) - { - MultiplayerUI.PushSystemMessage(Localize("The co-op run is already starting.")); - return; - } - } - + var streamEnabled = TryGetStreamEnabled(screen); StartHostServerOnly(); - var precommitted = PrecommitInitialHostRunSeed(out _, out var sequence, out var descriptor); - if (!precommitted || descriptor == null) - { - _log?.Warning("[NetMod][RunLaunch] Could not prepare the host launch descriptor"); - MultiplayerUI.PushSystemMessage(Localize("Could not prepare the co-op run launch.")); - return; - } - - if (!TryBeginInitialHostLaunch(screen, descriptor, out var beginError)) - { - CancelPrecommittedHostRunSeed("initial_launch_begin_failed"); - _log?.Warning( - "[NetMod][RunLaunch] Could not begin initial launch seq={Sequence}: {Error}", - sequence, - beginError); - MultiplayerUI.PushSystemMessage(Localize("Could not prepare the co-op run launch.")); - } + SetAuthoritativePendingNewGameLaunch(custom: false, streamEnabled); + RememberPendingLaunch(PendingLaunchAction.NewGame, custom: false, streamEnabled, sendToRemote: true); + TryLaunchNewGame(screen, custom: false, streamEnabled); } + // private static void GameDisposeHook(Hook_Game.orig_onDispose orig, Game self) + // { + // try + // { + // HandleWorldExit(isDisposeHook: true); + // } + // catch (Exception ex) + // { + // _log?.Warning("[NetMod] onDispose hook error: {Message}", ex.Message); + // } + + // orig(self); + // } + private static void HandleWorldExit(bool isDisposeHook = false) { + ResetHostDisconnectCountdown(); lock (Sync) { if (_worldExitHandled) return; @@ -1797,6 +1512,7 @@ private static void HandleWorldExit(bool isDisposeHook = false) NetRef = null; _waitingForHost = false; ResetClientConnectState(); + ResetLobbyReadyState(); _menuSelection = NetRole.None; ResetSteamState(); diff --git a/UI/GameMenuHooks.cs b/UI/GameMenuHooks.cs index 91f2673..de31a83 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/GameMenuHooks.cs @@ -1,3 +1,4 @@ +using System.Reflection; using dc.pr; using Hashlink.Virtuals; using HaxeProxy.Runtime; @@ -18,6 +19,7 @@ private static void InitializeMenuUiHooks() { LoadConfig(); InitializeMultiplayerSaveHooks(); + InitializeMultiplayerLaunchHooks(); Hook_TitleScreen.mainMenu += MainMenuHook; _menuHooksAttached = true; } @@ -35,22 +37,33 @@ private static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScree Hook_TitleScreen.addMenu += AddMenuHook; _addMenuHookRegistered = true; } + MainThreadDispatcher.SetMainMenuReady(); TryDisconnectWhenReturningToMainMenu(); StoreTitleScreen(self); - ConnectionUI.EnsureCreated(self); _mainMenuButtonAdded = false; + // Ensure a live ConnectionUI before any visibility toggle: returning mid-run + // destroys the previous TitleScreen tree and leaves a stale Instance.root. + ConnectionUI.EnsureCreated(self); + ResetOriginalMainMenuUiState(); + ConnectionUI.set_visible = false; orig(self); - if (!_mainMenuButtonAdded) - { - var label = GetText.Instance.GetString("Play multiplayer"); - var help = GetText.Instance.GetString("Host or join a multiplayer session"); - AddMenuButton(self, label, () => ShowMultiplayerMenu(self), help, MultiplayerMainMenuTextColor); - _mainMenuButtonAdded = true; - } + EnsureMainMenuMultiplayerButton(self); ProcessPendingOverlayJoinRequest(self); } + private static void ResetOriginalMainMenuUiState() + { + ResetHostDisconnectCountdown(); + _inHostStatusMenu = false; + _inClientWaitingMenu = false; + _menuSelection = NetRole.None; + _waitingForHost = false; + _clientConnecting = false; + _clientConnectAttempt = 0; + ConnectionUI.set_visible = false; + } + private static void ProcessPendingOverlayJoinRequest(TitleScreen screen) { if (_pendingOverlayJoinLobbyId is not { } lobbyId) @@ -81,35 +94,39 @@ private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( { ModEntry.PumpSteamCallbacksForOverlay(); GameMenu.ProcessMainThreadQueue(); + var wrappedCb = WrapQuitCallbackIfNeeded(str, cb); + var ret = orig(self, str, wrappedCb ?? cb, help, isEnable, color); - if (!_addingMultiplayerButton && !_mainMenuButtonAdded) + try { - var label = str?.ToString() ?? string.Empty; - var playLabel = GetText.Instance.GetString("Play"); - if (label.Equals(playLabel, StringComparison.OrdinalIgnoreCase)) + if (_suppressAutoButton) return ret; + if (_mainMenuButtonAdded) return ret; + if (!self.isMainMenu) return ret; + + var items = TitleScreenReflection.GetMemberValue(self, "menuItems", true); + if (items == null) + return ret; + var count = TitleScreenReflection.GetArrayLength(items); + if (count == 1) { - var wrappedCb = WrapQuitCallbackIfNeeded(str, cb); - var result = orig(self, str, wrappedCb ?? cb, help, isEnable, color); - - _addingMultiplayerButton = true; - try - { - var mpLabel = GetText.Instance.GetString("Play multiplayer"); - var mpHelp = GetText.Instance.GetString("Host or join a multiplayer session"); - AddMenuButton(self, mpLabel, () => ShowMultiplayerMenu(self), mpHelp, MultiplayerMainMenuTextColor); - _mainMenuButtonAdded = true; - } - finally { _addingMultiplayerButton = false; } - - return result; + int white = 0xFFFFFF; + var label = GetText.Instance.GetString("Play multiplayer").AsHaxeString(); + var helpStr = GetText.Instance.GetString("Host or join a multiplayer session").AsHaxeString(); + var colorHl = Ref.From(ref white); + var cbHl = new HlAction(() => ShowMultiplayerMenu(self)); + orig(self, label, cbHl, helpStr, null, colorHl); + _mainMenuButtonAdded = true; } } + catch (Exception ex) + { + _log?.Warning("[NetMod] addMenu hook failed: {Message}", ex.Message); + } - var wrapped = WrapQuitCallbackIfNeeded(str, cb); - return orig(self, str, wrapped ?? cb, help, isEnable, color); + return ret; } - private static HlAction? WrapQuitCallbackIfNeeded(dc.String? label, HlAction? callback) + private static HlAction? WrapQuitCallbackIfNeeded(dc.String label, HlAction? callback) { if (callback == null) return null; @@ -149,5 +166,69 @@ private static bool IsQuitMenuLabel(string label) return false; } + private static void EnsureMainMenuMultiplayerButton(TitleScreen screen) + { + try + { + var arr = TitleScreenReflection.GetMemberValue(screen, "menuItems", true); + var playMultiplayer = GetText.Instance.GetString("Play multiplayer"); + var playHelp = GetText.Instance.GetString("Host or join a multiplayer session"); + var playLabel = GetText.Instance.GetString("Play"); + var existingIdx = TitleScreenReflection.FindMenuIndexByLabel(arr, playMultiplayer); + if (existingIdx < 0) + { + TryAddMenuButton(screen, playMultiplayer, () => ShowMultiplayerMenu(screen), playHelp); + arr = TitleScreenReflection.GetMemberValue(screen, "menuItems", true); + } + _mainMenuButtonAdded = true; + MoveButtonAfterPlay(arr, playMultiplayer, playLabel); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to ensure main menu button order: {Message}", ex.Message); + } + } + + private static void MoveButtonAfterPlay(object? arrObj, string targetLabel, string anchorLabel) + { + if (arrObj == null) return; + try + { + var type = arrObj.GetType(); + var getDyn = type.GetMethod("getDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var removeDyn = type.GetMethod("removeDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var insertDyn = type.GetMethod("insertDyn", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getDyn == null || removeDyn == null || insertDyn == null) return; + + int len = TitleScreenReflection.GetArrayLength(arrObj); + int targetIdx = -1; + int anchorIdx = -1; + object? targetObj = null; + + for (int i = 0; i < len; i++) + { + var item = getDyn.Invoke(arrObj, new object[] { i }); + var label = TitleScreenReflection.GetMenuLabel(item); + if (targetIdx < 0 && label.Equals(targetLabel, StringComparison.OrdinalIgnoreCase)) + { + targetIdx = i; + targetObj = item; + } + if (anchorIdx < 0 && label.Equals(anchorLabel, StringComparison.OrdinalIgnoreCase)) + anchorIdx = i; + } + + if (targetIdx < 0 || anchorIdx < 0 || targetObj == null) return; + var desired = anchorIdx + 1; + if (targetIdx == desired) return; + + removeDyn.Invoke(arrObj, new[] { targetObj }); + insertDyn.Invoke(arrObj, new object[] { desired, targetObj }); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to reposition menu button: {Message}", ex.Message); + } + } } } diff --git a/UI/MainThreadDispatcher.cs b/UI/MainThreadDispatcher.cs new file mode 100644 index 0000000..30e484a --- /dev/null +++ b/UI/MainThreadDispatcher.cs @@ -0,0 +1,28 @@ +using Serilog; + +namespace DeadCellsMultiplayerMod.UI +{ + /// + /// Thin adapter used by the dev GameMenuHooks. Forwards to GameMenu's queue. + /// + internal static class MainThreadDispatcher + { + public static void Enqueue(Action? action) + { + if (action == null) + return; + + GameMenu.EnqueueMainThread(action); + } + + public static void Process(ILogger? log) + { + _ = log; + GameMenu.ProcessMainThreadQueue(); + } + + public static void SetMainMenuReady() + { + } + } +} diff --git a/server/server.NetNode.Build.cs b/server/server.NetNode.Build.cs index 7fed444..2ee085d 100644 --- a/server/server.NetNode.Build.cs +++ b/server/server.NetNode.Build.cs @@ -8,6 +8,21 @@ private static string BuildTaggedLine(string tag, int id, string payload) return $"{tag}|{id}|{payload}\n"; } + private static string BuildReadyLine(int id, bool ready) + { + return string.Create( + CultureInfo.InvariantCulture, + $"READY|{id}|{(ready ? 1 : 0)}\n"); + } + + private static string BuildCoopStateLine(int id, string? coopId, bool hasContinueSave) + { + var safeCoopId = SanitizeProtocolToken(coopId, 128); + return string.Create( + CultureInfo.InvariantCulture, + $"COOPID|{id}|{safeCoopId}|{(hasContinueSave ? 1 : 0)}\n"); + } + private static string BuildAnimLine(int id, string animName, int? queue, bool? gFlag) { var queuePart = queue.HasValue ? queue.Value.ToString(CultureInfo.InvariantCulture) : string.Empty; @@ -78,6 +93,17 @@ private static string SanitizeChatMessage(string? message) return safe; } + private static string SanitizeProtocolToken(string? value, int maxLength) + { + var safe = (value ?? string.Empty) + .Replace("|", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + + return safe.Length > maxLength ? safe[..maxLength] : safe; + } + private bool TryBuildLocalHpLine(out string line) { lock (_sync) diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index 4faa0ab..d3d1435 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -184,6 +184,25 @@ public void ClearMobSyncQueues() } } + /// + /// After Continue/LoadSave the session stays alive, but cached peer level/room markers still + /// describe the previous world. Clear them so ghost visibility uses fresh LEVEL/ROOM packets + /// instead of disposing the new GhostKing as "wrong room/level". + /// + public void ClearRemoteRoomMarkers() + { + lock (_sync) + { + foreach (var state in _remotes.Values) + { + state.LevelId = null; + state.RoomLevelId = null; + state.RoomId = null; + state.HasRoom = false; + } + } + } + public bool TryConsumeMobStates(out List snapshot) { lock (_sync) @@ -443,6 +462,21 @@ public bool TryGetRemoteUserSnapshots(out List snapshot) } } + public bool TryGetRemoteReady(int userId, out bool ready) + { + lock (_sync) + { + if (userId > 0 && _remotes.TryGetValue(userId, out var state) && state.HasRemote) + { + ready = state.Ready; + return true; + } + + ready = false; + return false; + } + } + public bool TryGetRemoteLevelId(out string? levelId) { lock (_sync) diff --git a/server/server.NetNode.Dispose.cs b/server/server.NetNode.Dispose.cs index 1821ec7..3f619ac 100644 --- a/server/server.NetNode.Dispose.cs +++ b/server/server.NetNode.Dispose.cs @@ -82,6 +82,9 @@ public void Dispose() _cachedHostHeroSkin = null; _cachedHostHeroHeadSkin = null; _cachedHostLevelGraphPayload = null; + _cachedHostCustomGameDataPayload = null; + _cachedHostCoopId = null; + _cachedHostHasContinueSave = false; _cachedHostMobsHpMult = null; _cachedHostBossesHpMult = null; } diff --git a/server/server.NetNode.Parse.cs b/server/server.NetNode.Parse.cs index a541b06..0146357 100644 --- a/server/server.NetNode.Parse.cs +++ b/server/server.NetNode.Parse.cs @@ -215,6 +215,20 @@ private static void ParseChatPayload(string payload, out int? parsedId, out stri message = payload; } + private static void ParseCoopStatePayload(string payload, out string coopId, out bool hasContinueSave) + { + coopId = string.Empty; + hasContinueSave = false; + if (string.IsNullOrWhiteSpace(payload)) + return; + + var parts = payload.Split(new[] { '|' }, 2); + coopId = SanitizeProtocolToken(parts[0], 128); + if (parts.Length >= 2) + hasContinueSave = string.Equals(parts[1], "1", StringComparison.Ordinal) || + string.Equals(parts[1], "true", StringComparison.OrdinalIgnoreCase); + } + private static List ParseMobStatesPayload(string payload) { var states = new List(); diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index dcb1672..4e9bd9f 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -266,6 +266,22 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + if (line.StartsWith("RESTART|", StringComparison.Ordinal)) + { + var payload = line["RESTART|".Length..]; + if (int.TryParse(payload, NumberStyles.Integer, CultureInfo.InvariantCulture, out var restartSeed)) + { + lock (_sync) _hasRemote = true; + GameMenu.ReceiveHostRunRestart(restartSeed); + } + else + { + _log.Warning("[NetNode] Malformed RESTART line: \"{line}\""); + } + + return true; + } + if (line.StartsWith("HXSYNC|", StringComparison.Ordinal)) { var payload = line["HXSYNC|".Length..]; @@ -330,6 +346,99 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + if (line.StartsWith("READY|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["READY|".Length..]; + var parts = payload.Split('|'); + if (parts.Length >= 2 && + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedReadyId)) + { + var effectiveId = forceSenderId ? senderId : parsedReadyId; + if (effectiveId.HasValue) + { + var ready = string.Equals(parts[1], "1", StringComparison.Ordinal); + lock (_sync) + { + var state = GetOrCreateRemoteLocked(effectiveId.Value); + state.Ready = ready; + state.HasRemote = true; + _hasRemote = true; + if (_primaryRemoteId == 0) + _primaryRemoteId = effectiveId.Value; + } + + GameMenu.ReceiveRemoteReady(effectiveId.Value, ready); + + if (_role == NetRole.Host && senderId.HasValue) + forwardLine = BuildReadyLine(effectiveId.Value, ready); + } + } + + return true; + } + + if (line.StartsWith("COOPID|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["COOPID|".Length..]; + var effectiveId = ResolvePayloadId(payload, senderId, out var coopPayload); + if (forceSenderId) + effectiveId = senderId; + + ParseCoopStatePayload(coopPayload, out var coopId, out var hasContinueSave); + if (effectiveId.HasValue) + { + lock (_sync) + { + var state = GetOrCreateRemoteLocked(effectiveId.Value); + state.CoopId = coopId; + state.HasContinueSave = hasContinueSave; + state.HasRemote = true; + _hasRemote = true; + if (_primaryRemoteId == 0) + _primaryRemoteId = effectiveId.Value; + } + + GameMenu.ReceiveRemoteCoopState(effectiveId.Value, coopId, hasContinueSave); + + if (_role == NetRole.Host && senderId.HasValue) + forwardLine = BuildCoopStateLine(effectiveId.Value, coopId, hasContinueSave); + } + + return true; + } + + if (line.StartsWith("LAUNCHMODE|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["LAUNCHMODE|".Length..]; + var parts = payload.Split('|'); + if (parts.Length >= 6 && + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var actionValue)) + { + var custom = string.Equals(parts[1], "1", StringComparison.Ordinal); + var streamEnabled = string.Equals(parts[2], "1", StringComparison.Ordinal); + var newCoopWorldPrepared = string.Equals(parts[3], "1", StringComparison.Ordinal); + var coopId = SanitizeProtocolToken(parts[4], 128); + var hostHasContinueSave = string.Equals(parts[5], "1", StringComparison.Ordinal); + + lock (_sync) + _hasRemote = true; + + GameMenu.ReceiveLaunchMode( + actionValue, + custom, + streamEnabled, + newCoopWorldPrepared, + coopId, + hostHasContinueSave); + } + else + { + _log.Warning("[NetNode] Malformed LAUNCHMODE line: \"{line}\"", line); + } + + return true; + } + if (line.StartsWith("CHAT|", StringComparison.OrdinalIgnoreCase)) { var payload = line["CHAT|".Length..]; @@ -512,6 +621,14 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + if (line.StartsWith("CGDATA|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["CGDATA|".Length..]; + lock (_sync) _hasRemote = true; + GameMenu.ReceiveCustomGameData(payload); + return true; + } + if (line.StartsWith("LEVEL|", StringComparison.OrdinalIgnoreCase)) { var payload = line["LEVEL|".Length..]; diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index d26ac91..10bccec 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -190,6 +190,16 @@ public void SendSeed(int sequence, int seed, string launchKind) _log.Information("[NetNode] Sent run seed seq={Sequence} seed={Seed} launch={LaunchKind}", sequence, seed, safeLaunchKind); } + public void SendRunRestart(int seed) + { + if (!HasAnyConnection()) + return; + + var line = string.Create(CultureInfo.InvariantCulture, $"RESTART|{seed}\n"); + _ = SendLineSafe(line); + _log.Information("[NetNode] Sent same-run restart seed {Seed}", seed); + } + public void SendSerializerSync(int seq, int uid) { if (_role != NetRole.Host) @@ -239,6 +249,62 @@ public void SendUsername(string username) _log.Information("[NetNode] Sent username {Username}", safe); } + public void SendReady(bool ready) + { + if (ID <= 0) + return; + + if (!HasAnyConnection()) + return; + + _ = SendLineSafe(BuildReadyLine(ID, ready)); + } + + public void SendCoopState(string? coopId, bool hasContinueSave) + { + var safeCoopId = SanitizeProtocolToken(coopId, 128); + if (_role == NetRole.Host) + { + lock (_hostCacheSync) + { + _cachedHostCoopId = safeCoopId; + _cachedHostHasContinueSave = hasContinueSave; + } + } + + if (!HasAnyConnection()) + return; + + var line = ID > 0 + ? BuildCoopStateLine(ID, safeCoopId, hasContinueSave) + : $"COOPID|{safeCoopId}|{(hasContinueSave ? 1 : 0)}\n"; + _ = SendLineSafe(line); + _log.Information( + "[NetNode] Sent coop id state hasId={HasId} hasContinue={HasContinue}", + !string.IsNullOrWhiteSpace(safeCoopId), + hasContinueSave); + } + + public void SendLaunchMode( + int action, + bool custom, + bool streamEnabled, + bool newCoopWorldPrepared, + string? coopId, + bool hostHasContinueSave) + { + if (_role != NetRole.Host) + return; + if (!HasAnyConnection()) + return; + + var safeCoopId = SanitizeProtocolToken(coopId, 128); + var line = string.Create( + CultureInfo.InvariantCulture, + $"LAUNCHMODE|{action}|{(custom ? 1 : 0)}|{(streamEnabled ? 1 : 0)}|{(newCoopWorldPrepared ? 1 : 0)}|{safeCoopId}|{(hostHasContinueSave ? 1 : 0)}\n"); + _ = SendLineSafe(line); + } + public void SendBossRune(int bossRune) { if (_role == NetRole.Host) @@ -369,6 +435,26 @@ public void SendGeneratePayload(string json) _log.Information("[NetNode] Sent Generate payload ({Length} bytes)", json.Length); } + public void SendCustomGameData(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return; + + if (!HasAnyConnection()) + { + _log.Information("[NetNode] Skip sending customGameData: no connected client"); + return; + } + + var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)); + lock (_hostCacheSync) + _cachedHostCustomGameDataPayload = encoded; + + // Base64 keeps the payload on a single protocol line (game JSON is indented). + SendRaw("CGDATA|" + encoded); + _log.Information("[NetNode] Sent customGameData ({Length} chars, {Encoded} encoded)", json.Length, encoded.Length); + } + public void SendHP(double life, double maxLife, double lif, double bonusLife, double recover) { diff --git a/server/server.NetNode.SendRouting.cs b/server/server.NetNode.SendRouting.cs index 270227d..335880a 100644 --- a/server/server.NetNode.SendRouting.cs +++ b/server/server.NetNode.SendRouting.cs @@ -206,6 +206,8 @@ private async Task SendKnownUsersToSteamClientSafe(SteamClientConnection connect continue; var line = BuildTaggedLine("USER", state.Id, username); await SendLineToSteamClientSafe(connection, line).ConfigureAwait(false); + await SendLineToSteamClientSafe(connection, BuildReadyLine(state.Id, state.Ready)).ConfigureAwait(false); + await SendLineToSteamClientSafe(connection, BuildCoopStateLine(state.Id, state.CoopId, state.HasContinueSave)).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(state.Skin)) { diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index 192a1c5..260df73 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -513,8 +513,11 @@ private async Task SendInitialStateToSteamClient(SteamClientConnection connectio string? cachedLevelDescPayload; string? cachedLevelSeedPayload; string? cachedLevelGraphPayload; + string? cachedCustomGameDataPayload; string? cachedHeroSkin; string? cachedHeroHeadSkin; + string? cachedCoopId; + bool cachedHasContinueSave; double? cachedMobsHpMult; double? cachedBossesHpMult; lock (_hostCacheSync) @@ -531,8 +534,11 @@ private async Task SendInitialStateToSteamClient(SteamClientConnection connectio cachedLevelDescPayload = _cachedHostLevelDescPayload; cachedLevelSeedPayload = _cachedHostLevelSeedPayload; cachedLevelGraphPayload = _cachedHostLevelGraphPayload; + cachedCustomGameDataPayload = _cachedHostCustomGameDataPayload; cachedHeroSkin = _cachedHostHeroSkin; cachedHeroHeadSkin = _cachedHostHeroHeadSkin; + cachedCoopId = _cachedHostCoopId; + cachedHasContinueSave = _cachedHostHasContinueSave; cachedMobsHpMult = _cachedHostMobsHpMult; cachedBossesHpMult = _cachedHostBossesHpMult; } @@ -541,6 +547,10 @@ private async Task SendInitialStateToSteamClient(SteamClientConnection connectio await SendLineToSteamClientSafe(connection, $"HXSYNC|{cachedSerializerSeq.Value}|{cachedSerializerUid.Value}\n").ConfigureAwait(false); if (cachedBossRune.HasValue) await SendLineToSteamClientSafe(connection, $"BOSSRUNE|{cachedBossRune.Value}\n").ConfigureAwait(false); + if (cachedCoopId != null) + await SendLineToSteamClientSafe(connection, BuildCoopStateLine(1, cachedCoopId, cachedHasContinueSave)).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(cachedCustomGameDataPayload)) + await SendLineToSteamClientSafe(connection, $"CGDATA|{cachedCustomGameDataPayload}\n").ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(cachedRunCommitPayload)) await SendLineToSteamClientSafe(connection, $"{RunLaunchWireCodec.CommitTag}|{cachedRunCommitPayload}\n").ConfigureAwait(false); if (cachedSeed.HasValue && cachedRunSeedSequence.HasValue) diff --git a/server/server.Tcp.cs b/server/server.Tcp.cs index d0f14d1..7b6fe3d 100644 --- a/server/server.Tcp.cs +++ b/server/server.Tcp.cs @@ -133,6 +133,9 @@ private async Task AcceptLoop(CancellationToken ct) string? cachedLevelDescPayload; string? cachedLevelSeedPayload; string? cachedLevelGraphPayload; + string? cachedCustomGameDataPayload; + string? cachedCoopId; + bool cachedHasContinueSave; double? cachedMobsHpMult; double? cachedBossesHpMult; lock (_hostCacheSync) @@ -149,6 +152,9 @@ private async Task AcceptLoop(CancellationToken ct) cachedLevelDescPayload = _cachedHostLevelDescPayload; cachedLevelSeedPayload = _cachedHostLevelSeedPayload; cachedLevelGraphPayload = _cachedHostLevelGraphPayload; + cachedCustomGameDataPayload = _cachedHostCustomGameDataPayload; + cachedCoopId = _cachedHostCoopId; + cachedHasContinueSave = _cachedHostHasContinueSave; cachedMobsHpMult = _cachedHostMobsHpMult; cachedBossesHpMult = _cachedHostBossesHpMult; } @@ -159,6 +165,12 @@ private async Task AcceptLoop(CancellationToken ct) if (cachedBossRune.HasValue) await SendLineToClientSafe(connection, $"BOSSRUNE|{cachedBossRune.Value}\n").ConfigureAwait(false); + if (cachedCoopId != null) + await SendLineToClientSafe(connection, BuildCoopStateLine(1, cachedCoopId, cachedHasContinueSave)).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(cachedCustomGameDataPayload)) + await SendLineToClientSafe(connection, $"CGDATA|{cachedCustomGameDataPayload}\n").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(cachedRunCommitPayload)) await SendLineToClientSafe(connection, $"{RunLaunchWireCodec.CommitTag}|{cachedRunCommitPayload}\n").ConfigureAwait(false); @@ -421,6 +433,8 @@ private async Task SendKnownUsersToClientSafe(ClientConnection connection) continue; var line = BuildTaggedLine("USER", state.Id, username); await SendLineToClientSafe(connection, line).ConfigureAwait(false); + await SendLineToClientSafe(connection, BuildReadyLine(state.Id, state.Ready)).ConfigureAwait(false); + await SendLineToClientSafe(connection, BuildCoopStateLine(state.Id, state.CoopId, state.HasContinueSave)).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/server/server.cs b/server/server.cs index 495ac26..f854dda 100644 --- a/server/server.cs +++ b/server/server.cs @@ -104,6 +104,9 @@ private sealed class RemoteState public int BonusLife; public int Recover; public string? Username; + public bool Ready; + public string? CoopId; + public bool HasContinueSave; public string? Skin; public string? Head; @@ -702,6 +705,9 @@ private bool IsSupersededNetworkSession() private string? _cachedHostHeroSkin; private string? _cachedHostHeroHeadSkin; private string? _cachedHostLevelGraphPayload; + private string? _cachedHostCustomGameDataPayload; + private string? _cachedHostCoopId; + private bool _cachedHostHasContinueSave; private double? _cachedHostMobsHpMult; private double? _cachedHostBossesHpMult;